Macro to copy Apple Notes Deep Link

This is extremely nice!

1 Like

Thank you @_jims ! Very nice!

1 Like

I'll take a look at the weekend – I'll probably do it with ObjC.import('sqlite3') in JS, rather than shelling out to /usr/bin/sqlite3, but should be fine, I think.

( Not a user of Notes, so I'll need to play around with it a bit )

2 Likes

Thanks! I’d be glad to help test if you’d like.

2 Likes

@_jims

Just looking at this – a couple of things:

  1. We can make multiple (including discontinuous ⌘Click) selections in Notes. The current build of your script is working well with single selections, but errors, I think with multiple selections ?
  2. I notice, FWIW, that we can derive a series of [label](url) strings directly in an Sqlite3 statement (one for each Notes UI selection)

In a JS idiom perhaps something like:

sqliteQueryMatchesLR(fpDB)(
    unwords([
        "SELECT '[' || ZTITLE1 || '](applenotes:note/' || ZIDENTIFIER || ')'",
        "FROM ZICCLOUDSYNCINGOBJECT",
        `WHERE Z_PK IN (${pks.join(",")})`
    ])
)

I've added a draft module for com.apple.Notes at:


As always, to test and use, you need to start by running the component macro:

Update map from bundleIDs to KM UUIDs (after new sub-macro added)

to register the new component.

1 Like

Thanks for point that out, @ComplexPoint. I've updated the macro above by revising the first portion of the AppleScript.

tell application "Notes"
	set theSelectedNotes to selection
	set theFirstNote to item 1 of theSelectedNotes
	set theNoteID to «class seld» of (theFirstNote as record)
	set theNoteName to name of note id theNoteID
end tell
2 Likes

Thank you, @ComplexPoint. I will test this soon and return feedback here.

Thanks @_jims !

I downloaded the macro, enabled it and it fires but is not adding anything to my clipboard - hmm. Not sure why.

Recorded short Loom: Troubleshooting Keyboard Maestro Macro Issue | Loom

Sorry, @gianthobbit. Please delete your current version of the macro and download it again. I somehow erred when I uploaded previously.

The current version should work if you are using Sequoia. I'm unsure with earlier versions of macOS. If you are using an earlier version and it does work, please report back. I've tested with Sequoia and iOS 18 only, i.e., a link created with this macro (for a note in iCloud) will open on any device (Mac, iPad, iPhone) that is using the same iCloud account.

( On multiple selection, FWIW, it aims to copy several links )


Expand disclosure triangle to view JS source
// Use in a Keyboard Maestro 
// "Execute JavaScript for Automation" action with
// 'Modern' syntax requires a preceding `return`.

// return (() => {

// (remove the `return` to test in Script Editor etc)
(() => {
    "use strict";

    ObjC.import("sqlite3");

    // MD Link(s) from com.apple.notes selection(s)
    // Rob Trew @2024
    // Ver 0.2

    // ---------------------- MAIN -----------------------
    const main = () => {
        const
            fpDB = [
                "~/Library",
                "Group Containers/group.com.apple.notes",
                "NoteStore.sqlite"
            ].join("/"),
            pks = Application("Notes").selection().map(
                x => x.id().split("/")
                    .slice(-1)[0].slice(1)
            );

        return either(
            msg => msg
        )(
            gen => Array.from(gen).join("\n")
        )(
            doesFileExist(fpDB)
                ? 0 < pks.length
                    ? sqliteQueryMatchesLR(fpDB)(
                        unwords([
                            "SELECT '['",
                            "|| ZTITLE1",
                            "|| '](applenotes:note/'",
                            "|| ZIDENTIFIER",
                            "|| ')'",
                            "FROM ZICCLOUDSYNCINGOBJECT",
                            "WHERE Z_PK IN",
                            `(${pks.join(",")})`
                        ])
                    )
                    : Left("Nothing selected in Notes.")
                : Left(`File not found: ${fpDB}`)
        );
    };

    // --------------------- SQLITE ----------------------

    // sqliteQueryMatchesLR :: FilePath -> 
    // SQL String -> Either String [String]
    const sqliteQueryMatchesLR = fpSqliteDB =>
        sqlQuery => {
            const
                SQLITE_OK = parseInt($.SQLITE_OK, 10),
                SQLITE_ROW = parseInt($.SQLITE_ROW, 10),
                ppDb = Ref();

            return fmapLR(
                tpl => unfoldr(
                    stmt => SQLITE_ROW === $.sqlite3_step(
                        stmt
                    )
                        ? Just(
                            Tuple(
                                $.sqlite3_column_text(
                                    stmt, 0
                                )
                            )(stmt)
                        )
                        : (
                            $.sqlite3_finalize(stmt),
                            $.sqlite3_close(tpl[0]),
                            Nothing()
                        )
                )(tpl[1])
            )(
                bindLR(
                    SQLITE_OK !== $.sqlite3_open(
                        filePath(fpSqliteDB), ppDb
                    )
                        ? Left($.sqlite3_errmsg(ppDb[0]))
                        : Right(ppDb[0])
                )(db => {
                    const ppStmt = Ref();

                    return (
                        SQLITE_OK !== $.sqlite3_prepare_v2(
                            db, sqlQuery, -1,
                            ppStmt, Ref()
                        )
                            ? Left($.sqlite3_errmsg(db))
                            : Right(
                                Tuple(db)(ppStmt[0])
                            )
                    );
                })
                // Accumulation of all available rows
                // in the table:
            );
        };

    // ----------------------- JXA -----------------------

    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();

        return $.NSFileManager
            .defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp).stringByStandardizingPath,
                ref
            ) && !ref[0];
    };

    // --------------------- GENERIC ---------------------

    // Just :: a -> Maybe a
    const Just = x => ({
        type: "Maybe",
        Just: x
    });


    // Nothing :: Maybe a
    const Nothing = () => ({
        type: "Maybe",
        Nothing: true
    });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        // A pair of values, possibly of
        // different types.
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


    // 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 = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr
            ? lr
            : mf(lr.Right);


    // 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 => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);


    // filePath :: String -> FilePath
    const filePath = s =>
        // The given file path with any tilde expanded
        // to the full user directory path.
        ObjC.unwrap(
            $(s).stringByStandardizingPath
        );

    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e
            ? e
            : Right(f(e.Right));


    // unfoldr :: (b -> Maybe (a, b)) -> b -> Gen [a]
    const unfoldr = f =>
        // A lazy (generator) list unfolded from a 
        // seed value by repeated application of f to a 
        // value until no residue remains. 
        // Dual to fold/reduce.
        // f returns either Nothing or 
        // Just (value, residue).
        // For a strict output list,
        // wrap with `list` or Array.from
        x => (
            function* () {
                let maybePair = f(x);

                while (!maybePair.Nothing) {
                    const valueResidue = maybePair.Just;

                    yield valueResidue[0];
                    maybePair = f(valueResidue[1]);
                }
            }()
        );


    // unwords :: [String] -> String
    const unwords = xs =>
        // A space-separated string derived
        // from a list of words.
        xs.join(" ");

    // MAIN ---
    return main();
})();
1 Like

Hi, @ComplexPoint. I've tested your addition of com.apple.Notes and it works brilliantly! :clap:

With the Apple Notes notes main browser window open (or closed, see below), if one or more notes are selected (including non-adjacent notes) and Copy as Markdown link is run, then a markdown link is written to the clipboard for each selected note.

One thing worth noting...

The link created by com.apple.Notes is based on the selection in the main notes browser even if another note is open (Window > Open Note in New Window) and in the foreground. Interestingly, this is the case even if the main notes browser is closed assuming there is a note window selected, again using Window > Open Note in New Window. In the case, the links are based on the notes that were selected before the main notes browser was closed.

Thanks _@jims, that's helpful.

The link created by com.apple.Notes is based on the selection in the main notes browser

  • I agree that it would seem more intuitive to target an individual note window when one of those has the UI foreground
  • the .selection() property exposed to osascript only yields information about main browser window selection
  • members of the api's .windows() collection have (non-unique) .name() properties, but no identifier that ties them to a particular db record.

Have you found a way around that in your script ?

I'm personally relying on a notification for visual feedback about what has been copied, but perhaps it would make sense to detect the condition in which the foreground window is not the main Notes browser, and direct the user to make a selection there ?