Convert Safari bookmarks to text [SOLVED]

I don't know if this is even possible, but here it goes:

I have a folder on my bookmarks called "Check Later" which works like an inbox where I just dump websites I need to check... well, later. Something like this:

What I do every week is I create a new Obsidian note and convert those links into links in that note (1 link per bookmark) so then when I visit the website, I have the link and I can take notes underneath it, in the note.

Is there a way for KM to check which links I have in that folder and save them as a text file? Right now what I do is I click the link to open the website and copy/paste the link manually.

I tried to convert the Bookmarks file to JSON, which would have made it simpler to process, but the conversion failed with an invalid object error. It is possible to convert it to text, which I did, and then thought about trying to use xpath to get all the children of the parent folder. But someone much stronger at xpath stuff than I will have to chime in, but what I saw in the text file wasn't encouraging: The children of a parent folder aren't listed as members of the parent folder, and the IDs of the child elements don't tie back to anything I could find with the parent.

Perhaps @ComplexPoint will have an idea as to how to do this.

-rob.

1 Like

Mmm ... this:

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // MD listing of any Safari bookmarks found
    // in a bookmark folder called "Check Later"

    const kmvar = {
        "local_Bookmark_folder": "Check Later"
    };

    const main = () => {
        const
            fpBookmarks = filePath("~/Library/Safari/Bookmarks.plist"),
            bookMarkFolderName = kmvar.local_Bookmark_folder;

        return doesFileExist(fpBookmarks)
            ? (() => {
                const
                    mbCheckLater = bindMay(
                        findTree(
                            x => bookMarkFolderName === x.Title
                        )(
                            readPlist(fpBookmarks)
                        )
                    )(
                        dict => {
                            const
                                marks = dict.Children.map(mark => {
                                    const
                                        url = mark.URLString || "",
                                        subDict = mark.URIDictionary,
                                        title = subDict
                                            ? subDict.title
                                            : "";

                                    return `[${title}](${url})`;
                                });

                            return Just(
                                0 < marks.length
                                    ? marks.join("\n\n")
                                    : [
                                        "No bookmarks found in ",
                                        `'${bookMarkFolderName}'`
                                    ]
                                    .join("")
                            );
                        }
                    );

                return mbCheckLater.Nothing
                    ? `Folder not found: '${bookMarkFolderName}'`
                    : mbCheckLater.Just;
            })()
            : `Bookmarks not found at: ${fpBookmarks}`;
    };

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

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

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

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

    // readPlist :: FilePath -> Object
    const readPlist = path => {
        const
            fp = $(path).stringByStandardizingPath,
            uw = ObjC.deepUnwrap;

        return uw(
            $.NSDictionary.dictionaryWithContentsOfFile(fp)
        ) || uw(
            $.NSArray.arrayWithContentsOfFile(fp)
        );
    };


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

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

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


    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = mb =>
    // Nothing if mb is Nothing, or the application of the
    // (a -> Maybe b) function mf to the contents of mb.
        mf => mb.Nothing
            ? mb
            : mf(mb.Just);


    // findTree :: (a -> Bool) -> Tree a -> Maybe a
    const findTree = p => {
    // The first of any node values in the tree which match
    // the predicate p.
    // (For all matches, see treeMatches)
        const go = tree => {
            const x = root(tree);

            return p(x)
                ? Just(x)
                : (() => {
                    const
                        xs = nest(tree),
                        n = xs.length;

                    return Boolean(n)
                        ? until(
                            ([i, mb]) => n <= i || ("Just" in mb)
                        )(
                            ([i]) => [1 + i, go(xs[i])]
                        )(
                            [0, Nothing()]
                        )[1]
                        : Nothing();
                })();
        };

        return go;
    };


    // root :: Tree a -> a
    const root = tree =>
    // The value attached to a tree node.
        tree;


    // nest :: Tree a -> [a]
    const nest = tree =>
    // Allowing for lazy (on-demand) evaluation.
    // If the nest turns out to be a function –
    // rather than a list – that function is applied
    // here to the root, and returns a list.
        tree.Children || [];


    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p =>
    // The value resulting from successive applications
    // of f to f(x), starting with a seed value x,
    // and terminating when the result returns true
    // for the predicate p.
        f => x => {
            let v = x;

            while (!p(v)) {
                v = f(v);
            }

            return v;
        };

    return main();
})();

is running for me from Visual Studio Code with the Code Runner extension, and also in CodeRunner – Programming Editor for macOS, but not, for some reason either from:

  • Script Editor, or
  • a Keyboard Maestro Execute JavaScript for Automation action.

Permissions restriction in access to ~/Library/Safari/Bookmarks.plist ?


In the Console.app:

error 17:23:54.330610+0000 kernel System Policy: osascript(9706) deny(1) file-read-data /Users/houthakker/Library/Safari/Bookmarks.plist

@peternlewis any sense of why CodeRunner – Programming Editor for macOS and Visual Studio Code get away with this ?

Might there be an additional permission which I haven't switched on for Keyboard Maestro ?

1 Like

Thanks for checking this.
So if I export he bookmarks in Safari, I get the HTML file. If I then use the Read File action to read that file I get this:

I was able to select everything before the words "Check Later".
Then I can't seem to find how to select everything after the first </DL> after "Check Later". If I can I will be able to then clean up everything and get the URLs and titles, but with my basic Regex knowledge, I'm not getting there...

Wow, I see that you are way ahead of what I was trying to do with Regex...
What if you first copy the plist file to a different folder, would that work around the permissions?

Another option, in case your script doesn't work:
Would your script (modified, of course) be able to read the HTML file when I export the bookmarks?
If your first approach doesn't work because of an issue with KM, I don't mind exporting the HTML file and then do something with that file, if that makes it possible

This kind of thing works here if, for example, I first manually copy from ~/Library/Safari/Bookmarks.plist to ~/Desktop/Bookmarks.plist (where manually means in the Finder GUI):

Safari bookmarks found in named bookmark folder.kmmacros (7,9 Ko)

But on this system at least, I still hit a permissions restriction if I try to do that copying in the macro, either in JS, or with a Keyboard Maestro Copy File action.

OK, I think the solution might be (deep breath – if this is what you want to do)

System Preferences > Privacy & Security > Full Disk Access > [Check] Keyboard Maestro

If you are happy to grant that level of access, then you could try something like:

Safari bookmarks found in named bookmark folder.kmmacros (9,4 Ko)

Expand disclosure triangle to view JS source for KM 11 'modern syntax' setting
return (() => {
    "use strict";

    // MD listing of any Safari bookmarks found
    // in a bookmark folder called "Check Later"

    const main = () => {
        const
            fpBookmarks = filePath(kmvar.local_PLIST_Path),
            bookMarkFolderName = kmvar.local_Bookmark_folder;

        return doesFileExist(fpBookmarks)
            ? (() => {
                const
                    mbCheckLater = bindMay(
                        findTree(
                            x => bookMarkFolderName === x.Title
                        )(
                            readPlist(fpBookmarks)
                        )
                    )(
                        maybeMDLinksFromDict(bookMarkFolderName)
                    );

                return mbCheckLater.Nothing
                    ? `Folder not found: '${bookMarkFolderName}'`
                    : mbCheckLater.Just;
            })()
            : `Bookmarks not found at: ${fpBookmarks}`;
    };

    const maybeMDLinksFromDict = folderName =>
        dict => {
            const
                marks = dict.Children.map(mark => {
                    const
                        url = mark.URLString || "",
                        subDict = mark.URIDictionary,
                        title = subDict
                            ? subDict.title
                            : "";

                    return `[${title}](${url})`;
                });

            return Just(
                0 < marks.length
                    ? marks.join("\n\n")
                    : [
                        "No bookmarks found in ",
                        `'${folderName}'`
                    ]
                    .join("")
            );
        };

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

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

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

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

    // readPlist :: FilePath -> Object
    const readPlist = path => {
        const
            fp = $(path).stringByStandardizingPath,
            uw = ObjC.deepUnwrap;

        return uw(
            $.NSDictionary.dictionaryWithContentsOfFile(fp)
        ) || uw(
            $.NSArray.arrayWithContentsOfFile(fp)
        );
    };


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

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

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


    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = mb =>
    // Nothing if mb is Nothing, or the application of the
    // (a -> Maybe b) function mf to the contents of mb.
        mf => mb.Nothing
            ? mb
            : mf(mb.Just);


    // findTree :: (a -> Bool) -> Tree a -> Maybe a
    const findTree = p => {
    // The first of any node values in the tree which match
    // the predicate p.
    // (For all matches, see treeMatches)
        const go = tree => {
            const x = root(tree);

            return p(x)
                ? Just(x)
                : (() => {
                    const
                        xs = nest(tree),
                        n = xs.length;

                    return Boolean(n)
                        ? until(
                            ([i, mb]) => n <= i || ("Just" in mb)
                        )(
                            ([i]) => [1 + i, go(xs[i])]
                        )(
                            [0, Nothing()]
                        )[1]
                        : Nothing();
                })();
        };

        return go;
    };


    // root :: Tree a -> a
    const root = tree =>
    // The value attached to a tree node.
        tree;


    // nest :: Tree a -> [a]
    const nest = tree =>
    // Allowing for lazy (on-demand) evaluation.
    // If the nest turns out to be a function –
    // rather than a list – that function is applied
    // here to the root, and returns a list.
        tree.Children || [];


    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p =>
    // The value resulting from successive applications
    // of f to f(x), starting with a seed value x,
    // and terminating when the result returns true
    // for the predicate p.
        f => x => {
            let v = x;

            while (!p(v)) {
                v = f(v);
            }

            return v;
        };

    return main();
})();
1 Like

Yes, generally you need Full Disk Access to poke around the Library folder.

Or the various Folders access to even just use the Documents folder.

At least you can do that for Keyboard Maestro - I have Launch Agents that stopped working because they access the Documents folder and there is no way to give them permission.

1 Like

Generalising a bit to a (KM 11) subroutine (JSON listing of links in named folder) and a macro (specifying a folder name and serialising the JSON as Markdown):

Safari bookmarks Macros.kmmacros (18 KB)

(For a broader listing of Favourites, try the folder name BookmarksBar)


Expand disclosure triangle to view JS source
return (() => {
    "use strict";

    // MD listing of any Safari bookmarks found
    // in a bookmark folder called "Check Later"

    // const kmvar = {
    //     "local_PLIST_Path": "~/Library/Safari/Bookmarks.plist",
    //     "local_Bookmark_folder": "BookmarksBar"
    // };

    const main = () => {
        const
            fpBookmarks = filePath(kmvar.local_PLIST_Path),
            bookMarkFolderName = kmvar.local_Bookmark_folder;

        return doesFileExist(fpBookmarks)
            ? (() => {
                const
                    mbLinks = bindMay(
                        findTree(
                            x => bookMarkFolderName === x.Title
                        )(
                            readPlist(fpBookmarks)
                        )
                    )(
                        maybeLinksFromDict(bookMarkFolderName)
                    );

                return mbLinks.Nothing
                    ? `Folder not found: '${bookMarkFolderName}'`
                    : JSON.stringify(mbLinks.Just, null, 2);
            })()
            : `Bookmarks not found at: ${fpBookmarks}`;
    };

    const maybeLinksFromDict = folderName =>
        dict => {
            const
                marks = dict.Children.flatMap(mark => {
                    const url = mark.URLString;

                    return Boolean(url)
                        ? (() => {
                            const
                                subDict = mark.URIDictionary,
                                title = subDict
                                    ? subDict.title
                                    : "";

                            return [{
                                title,
                                url
                            }];
                        })()
                        : [];
                });

            return Just(
                0 < marks.length
                    ? marks
                    : [
                        "No bookmarks found in ",
                        `'${folderName}'`
                    ]
                    .join("")
            );
        };

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

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

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

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

    // readPlist :: FilePath -> Object
    const readPlist = path => {
        const
            fp = $(path).stringByStandardizingPath,
            uw = ObjC.deepUnwrap;

        return uw(
            $.NSDictionary.dictionaryWithContentsOfFile(fp)
        ) || uw(
            $.NSArray.arrayWithContentsOfFile(fp)
        );
    };


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

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

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


    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = mb =>
    // Nothing if mb is Nothing, or the application of the
    // (a -> Maybe b) function mf to the contents of mb.
        mf => mb.Nothing
            ? mb
            : mf(mb.Just);


    // findTree :: (a -> Bool) -> Tree a -> Maybe a
    const findTree = p => {
    // The first of any node values in the tree which match
    // the predicate p.
    // (For all matches, see treeMatches)
        const go = tree => {
            const x = root(tree);

            return p(x)
                ? Just(x)
                : (() => {
                    const
                        xs = nest(tree),
                        n = xs.length;

                    return Boolean(n)
                        ? until(
                            ([i, mb]) => n <= i || ("Just" in mb)
                        )(
                            ([i]) => [1 + i, go(xs[i])]
                        )(
                            [0, Nothing()]
                        )[1]
                        : Nothing();
                })();
        };

        return go;
    };


    // root :: Tree a -> a
    const root = tree =>
    // The value attached to a tree node.
        tree;


    // nest :: Tree a -> [a]
    const nest = tree =>
    // Allowing for lazy (on-demand) evaluation.
    // If the nest turns out to be a function –
    // rather than a list – that function is applied
    // here to the root, and returns a list.
        tree.Children || [];


    // until :: (a -> Bool) -> (a -> a) -> a -> a
    const until = p =>
    // The value resulting from successive applications
    // of f to f(x), starting with a seed value x,
    // and terminating when the result returns true
    // for the predicate p.
        f => x => {
            let v = x;

            while (!p(v)) {
                v = f(v);
            }

            return v;
        };

    return main();
})();
1 Like

For a textual listing of the links in the Safari Reading List, try, as folder title:

com.apple.ReadingList

1 Like

@ComplexPoint Many thanks for this macro. Several years ago I was looking for a solution to get all my open tabs on my iPhone/iPad onto my Mac / into a text file on my Mac. I ended up with a 3 step solution. With this macro it is now only 2 steps and I save 5-10 minutes each time...

2 Likes

I actually have that already set to full access, but when I tried to use the Read File action or Copy File on that particular plist file, I couldn't, but I was able to do it with another plist inside the same folder, so it seems that the issue is with that particular file.

I used your macro and it works like a charm, though! :slight_smile:
Thank you SO much for taking the time to share this and fixing it. It seems that @Tomaso71 was also in need of such macro, so you ended up helping two people with just 1 macro :wink: Hopefully more people will find this useful too! You're the best! Thank you! :muscle:

1 Like

Even with Full Access I wasn't able to use the Read File or Copy File on the Bookmarks.plist

But the first macro shared by @ComplexPoint worked like a charm, though.

@ComplexPoint without trying to push too much...
Would it be fairly easy to use a copy of the same script, adapted to delete the bookmarks in that same folder?
So I would have a macro to find the links and save them to an .md file (the current script you shared) and once I confirm that the file contains the links, I would run another macro to delete the bookmarks in that same Check Later folder.
Is it possible?

If this is too much work, it's ok. I completely understand it. :+1:

Reading and querying that Safari .plist should be safe enough,
but I would personally hesitate to attempt any writing to it.

( and I'm afraid I also have a rule of thumb that I don't share or offer scripts that involve any kind of deletion :slight_smile: )

1 Like

Sure, I understand. No worries. Being able to have the links is the hard part of this process anyway. Deleting is just a matter of opening the bookmarks window and deleting them after them being selected. You already saved me some time and work. Thank you so much :slight_smile:

1 Like