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:
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?
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.
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.
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 )
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.
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.
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)
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();
})();
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.
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).