JavaScript to add current Safari tab to a Links: project, adding a dated @tag

Over the years, I have been using various scripts to capture links from Safari into a Links: project in TaskPaper.

I started with AppleScript, then eventually moved to a AppleScript/JavaScript combo. @ComplexPoint made me one that used Birch, which catered to my excessive desire to not have TaskPaper running during the capture. I’ve finally moved the scope to a simpler and more efficient JavaScript.

The script takes the title and URL of the current tab in Safari, formats it into a task and then places it at the top of a Links project in TaskPaper. If the Links: project is absent, it is created before the insertion.

I have added a dated @link tag to the item, so I can identify when it was added. This adds some interesting search options for my links management in TaskPaper. An example:

Here is the JavaScript, which I run from Keyboard Maestro:

'use strict';

const app = Application.currentApplication()
app.includeStandardAdditions = true

const d = new Date();
const dtd = d.getFullYear() + "-" + ('0' + (d.getMonth()+1)).slice(-2) + "-" + ('0' + d.getDate()).slice(-2);

const safariApp = Application("Safari");

const SafDoc = safariApp.documents[0];
const varTitle = SafDoc.name();
const varURL = SafDoc.url();

const varTask = "- " + varTitle + " → " + varURL + " @link(" + dtd + ")";

function TaskPaperContext(editor, options) {
	const outline = editor.outline
	const project = outline.evaluateItemPath('project Links')[0]
	if (project == null) {
		project = outline.createItem('Links:')
		outline.root.insertChildrenBefore(project, outline.root.firstChild)
	}
		project.insertChildrenBefore(
			outline.createItem(options.url),
			project.firstChild
	)
}

Application('TaskPaper').documents[0].evaluate({
  script: TaskPaperContext.toString(),
  withOptions: { url: varTask },
});

For capturing a single link, I am very happy with the above.

I would like to make a second version that captures all of the open tabs in Safari, but I have yet to crack that, as my JavaScript knowledge continues to progress slowly. Looking at ComplexPoint’s Birch code has baffled me.

Any advice on how to use a if statement to capture all of the Safari tabs?

1 Like

If you switch from your reference to the front document:

.documents[0]

to a reference to the tabs of the front window:

.windows.at(0).tabs

perhaps binding a name to that reference, for example:

const 
    tabs = Application("Safari")
    .windows.at(0).tabs

property names called on that reference will yield lists of all the corresponding property values (in order of the tabs) for the whole tab collection.

So:

tabs.name() will return a full list of tab names, and

tabs.url() will return a full list of urls.

As these are two parallel ordered lists, we can zip them back up into a single list of pairs.

Lots of ways of doing that – I would personally write something like this, for example:

(scroll right to the bottom to see and copy the full script)

(() => {
    "use strict";

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const
            tabs = Application("Safari")
            .windows.at(0).tabs,
            dateString = taskPaperDateString(new Date())
            .slice(0, 10);

        return zipWith(
                name => link =>
                `- ${name} → ${link} @link(${dateString})`
            )(
                tabs.name()
            )(
                tabs.url()
            )
            .join("\n");
    };


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

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();


    // taskPaperDateString :: Date -> String
    const taskPaperDateString = dte => {
        const [d, t] = iso8601Local(dte).split("T");

        return [d, t.slice(0, 5)].join(" ");
    };


    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

    return main();
})();
2 Likes

That is amazing @complexpoint ! Thank you!

I am trying to integrate it into my previous code, and I am making a mistake somewhere. Can you tell me where I am going wrong?

(() => {
    "use strict";

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const
            tabs = Application("Safari")
            .windows.at(0).tabs,
            dateString = taskPaperDateString(new Date())
            .slice(0, 10);

        return zipWith(
                name => link =>
                `- ${name} → ${link} @link(${dateString})`
            )(
                tabs.name()
            )(
                tabs.url()
            )
            .join("\n");
    };

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

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();

    // taskPaperDateString :: Date -> String
    const taskPaperDateString = dte => {
        const [d, t] = iso8601Local(dte).split("T");

        return [d, t.slice(0, 5)].join(" ");
    };

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

function TaskPaperContext(editor, options) {
    const outline = editor.outline
    const project = outline.evaluateItemPath('project Links')[0]
    if (project == null) {
        project = outline.createItem('Links:')
        outline.root.insertChildrenBefore(project, outline.root.firstChild)
    }
        project.insertChildrenBefore(
            outline.createItem(options.url),
            project.firstChild
    )
}

Application('TaskPaper').documents[0].evaluate({
  script: TaskPaperContext.toString(),
  withOptions: { url: main },
  });
})();

Side question: which app do you use to debug JavaScript code?

The only problem, I think, is that you are passing in a name bound to a function definition, rather than passing in the result of evaluating that function.

To get the defined value, you need to evaluate main, so:

withOptions: { url: main() }

// instead of withOptions: { url: main }

(the added parentheses after the name main signify the evaluation)

`

which app do you use to debug JavaScript code?

As I’m not using variables (just constants), I don’t find myself doing much debugging of the classic kind – tracking the state of a variable over time.

I just work in a text editor (Visual Studio Code with the Run Code extension), and check the values returned by individual functions and sub-assemblies as I build the full value up.

1 Like

Thank you again!

It runs well with one tab. But if I try it on multiple tabs in Safari, I get:

Error -609: Connection is invalid.

Ideas?

That’s an issue on the TaskPaper side – I haven’t got time to test now, but I think the problem might be that options.url is bound to a string of several lines (when you have several open tabs), whereas your TaskPaper incantation appears to assume the creation of a single item.

A first experiment might be to bind options.url to an Array of strings (removing .join("\n") from the zipWith expression) rather than a single string.

In the TaskPaper context you could then iteratively define a series of new items, one by one.

1 Like

Not sure if you have untangled that yet.

If not, some elements here that you might be able to reuse:

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

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const windows = Application("Safari").windows;

        return either(
            // Left channel message in case of a problem.
            msg => alert(
                "Copy Safari tab list to TaskPaper"
            )(msg)
        )(
            // Simple string value returned if all is well.
            x => x
        )(
            bindLR(
                0 < windows.length ? (
                    Right(windows.at(0).tabs)
                ) : Left("No windows open in Safari")
            )(tabs => {
                const
                    dateString = taskPaperDateString(
                        new Date()
                    ).slice(0, 10),
                    linkStrings = zipWith(
                        name => link =>
                        `- ${name} → ${link} @link(${dateString})`
                    )(
                        tabs.name()
                    )(
                        tabs.url()
                    );

                return Right(
                    Application("TaskPaper")
                    .documents[0]
                    .evaluate({
                        script: `${TaskPaperContext}`,
                        withOptions: {
                            projectName: "Links",
                            linkTexts: linkStrings
                        }
                    })
                );
            })
        );
    };

    // -------------------- TASKPAPER --------------------

    // TaskPaperContext :: Editor -> Dict -> String
    const TaskPaperContext = (editor, options) => {
        const tp3Main = () => {
            const
                outline = editor.outline,
                xs = options.linkTexts,
                targetProject = projectFoundOrCreated(
                    outline
                )(options.projectName);

            outline.groupUndoAndChanges(() =>
                xs.forEach(
                    txt => targetProject.appendChildren(
                        outline.createItem(txt)
                    )
                )
            );

            return xs.join("\n");
        };

        // projectFoundOrCreated :: TP3Outline ->
        // String -> Bool -> TP3Item
        const projectFoundOrCreated = outline =>
            // A reference to a TaskPaper Project item,
            // either found in the outline, or created
            // at the top of it.
            projectName => {
                const
                    k = projectName.trim(),
                    matches = outline.evaluateItemPath(
                        `//project "${k}"`
                    ),
                    blnFound = 0 < matches.length,
                    project = blnFound ? (
                        matches[0]
                    ) : outline.createItem(`${k}:`);

                return (
                    blnFound || outline.insertItemsBefore(
                        project,
                        outline.root.firstChild
                    ),
                    project
                );
            };

        return tp3Main();
    };

    // ----------------------- 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
            );
        };

    // --------------------- 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);

    // 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);

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();

    // taskPaperDateString :: Date -> String
    const taskPaperDateString = dte => {
        const [d, t] = iso8601Local(dte).split("T");

        return [d, t.slice(0, 5)].join(" ");
    };

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

    return main();
})();

( Let me know if the way is obstructed by any particular opacities )

1 Like

Yesterday was busy for me, so I have yet to tackle it.

I will work on untangling tonight. I will let you know as obstructions come up. This looks like a notable challenge for me, and it should prove to be a good learning experience.

Thank you @complexpoint !

Doing a bit of testing, and figuring out some of the code.

One thing that is confusing, is that it sometimes gets the tabs rear window of Safari (if I have multiple Safari windows open). This may be related to how many tabs are opened in the front document. Will continue to test, in order to track it down.

Odd. Further tests are working with the front window. Will continue to test.

In subsequent tests, the tabs from the front window are arriving—I don’t know what I did to get the other results.

I’d like to insert the new links at the top of the project. I’ve tried the following, but they appear in reverse order.

Original code:

outline.groupUndoAndChanges(() =>
                xs.forEach(
                    txt => targetProject.appendChildren(
                        outline.createItem(txt)

My attempt, with the links appearing in reverse order:

outline.groupUndoAndChanges(() =>
                xs.forEach(
                    txt => targetProject.insertChildrenBefore(
                        outline.createItem(txt), targetProject.firstChild

I suspect that this is because the new links are processed one by one (rather than as a group).

Thoughts on how to get the list inserted at the top in their original order?

Other questions:

Can all of the Safari windows be included? I am guessing that would be related to const windows, but I don’t know how to change that (I’ve tried variations of window(s) and document(s), but without success.

${} means string, right?

Forgive this (probably stupid) newbie question: why did you use two parallel lists?
(as opposed to combining the name and link up front)

Thank you again for you help!

With Jesse’s help, here is the finished script. It places the new links at the top:

(() => {
    "use strict";

    // Rob Trew @2021

    // main :: IO ()
    const main = () => {
        const windows = Application("Safari").windows;

        return either(
            // Left channel message in case of a problem.
            msg => alert(
                "Copy Safari tab list to TaskPaper"
            )(msg)
        )(
            // Simple string value returned if all is well.
            x => x
        )(
            bindLR(
                0 < windows.length ? (
                    Right(windows.at(0).tabs)
                ) : Left("No windows open in Safari")
            )(tabs => {
                const
                    dateString = taskPaperDateString(
                        new Date()
                    ).slice(0, 10),
                    linkStrings = zipWith(
                        name => link =>
                        `- ${name} → ${link} @link(${dateString})`
                    )(
                        tabs.name()
                    )(
                        tabs.url()
                    );

                return Right(
                    Application("TaskPaper")
                    .documents[0]
                    .evaluate({
                        script: `${TaskPaperContext}`,
                        withOptions: {
                            projectName: "Links",
                            linkTexts: linkStrings
                        }
                    })
                );
            })
        );
    };

    // -------------------- TASKPAPER --------------------

    // TaskPaperContext :: Editor -> Dict -> String
    const TaskPaperContext = (editor, options) => {
        const tp3Main = () => {
            const
                outline = editor.outline,
                xs = options.linkTexts,
                targetProject = projectFoundOrCreated(
                    outline
                )(options.projectName);

		outline.groupUndoAndChanges(() => {
			let children = xs.map(txt => outline.createItem(txt))
			targetProject.insertChildrenBefore(children, targetProject.firstChild);
		});

            return xs.join("\n");
        };

        // projectFoundOrCreated :: TP3Outline ->
        // String -> Bool -> TP3Item
        const projectFoundOrCreated = outline =>
            // A reference to a TaskPaper Project item,
            // either found in the outline, or created
            // at the top of it.
            projectName => {
                const
                    k = projectName.trim(),
                    matches = outline.evaluateItemPath(
                        `//project "${k}"`
                    ),
                    blnFound = 0 < matches.length,
                    project = blnFound ? (
                        matches[0]
                    ) : outline.createItem(`${k}:`);

                return (
                    blnFound || outline.insertItemsBefore(
                        project,
                        outline.root.firstChild
                    ),
                    project
                );
            };

        return tp3Main();
    };

    // ----------------------- 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
            );
        };

    // --------------------- 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);

    // 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);

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();

    // taskPaperDateString :: Date -> String
    const taskPaperDateString = dte => {
        const [d, t] = iso8601Local(dte).split("T");

        return [d, t.slice(0, 5)].join(" ");
    };

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

    return main();
})();

I truly appreciate both @complexpoint and @jessegrosjean ! You both rock!

1 Like

Apple Events are slow and expensive. If you repeatedly fetch individual names and urls, each tab costs you two apple events (one for name, one for url), and the more tabs you have the slower that becomes.

If you use the batch fetching technique, you get the full set of values (for a single property) for just one AppleEvent, and that low price is constant even if the number of tabs gets large.

But above all, at coding time, rather than run-time, I just find it cleaner and simpler to avoid having to set up loops and check them – loops and mutable variables are the main bug factories, and we don’t really need either of them – a map or reduce or filter always has fewer moving parts, and takes care of more on our behalf.

To put it another way, I find code that “does things” turns out to be messier and more time-consuming than code that just defines the values that we are after.

Excellent explanation @complexpoint ! Thank you for the insight!

Added to wiki. Great reference for those trying to learn how to do some scripts for TaskPaper in JavaScript

1 Like

Ah, missed this:

A string `enclosed between back-tick characters` (rather than quotes) is a template literal or template string.

Template literals (Template strings) - JavaScript | MDN

You can embed expressions ( names or literal expressions like 2 + 2 ) within a template string by wrapping each expression between ${ and }

And you are right, in the sense that if the value enclosed in ${ value } is not a string (perhaps a number or a function), then coercion occurs to derive a usable string value (as if .toString() had been applied).

1 Like