My working TaskPaper document often contains a number of MD-formatted links to local and web resources which I need to click quite often during a working session.
I also have various project files, each of which contains a number of (markdown formatted) links to various things that I need.
Here is a macro for showing a menu of links in response to some trigger like a hotkey.
It's just a lazy alternative to opening the file and hunting through it for the links.
(The menu will, of course, open any links that are selected).
It can be configured either to:
- List the (sorted) labels of all the MD links found anywhere in the front TaskPaper document, or
- List the (sorted) labels of all the MD links found anywhere in a text file specified by by a filePath in KM variable.
Here are two copies of this same macro, one copy configured for the TaskPaper front document, and the other for a given file path:
Sorted menus of links in a file.kmmacros (73.5 KB)
JS source
(() => {
'use strict';
// Rob Trew @2020
// Menu of the labels of any links
// (assumed to be in MD format) in the front
// TaskPaper document, or in a file at the filePath
// specified by a Keyboard Maestro variable.
// If one or more labels are chosen in the menu,
// the corresponding links are opened.
// --------------------- SETTINGS ----------------------
// EITHER a menu of links in the TP3 front document,
const showLinksInTaskPaperFrontDoc = true;
// OR a menu of links in a file at a given path,
// specified by a Keyboard Maestro variable.
// e.g: '~/projects/activeProjects.taskpaper'
const filePathKMVarName = 'linkListFilePath';
// --------------------- MENU CODE ---------------------
// main :: IO ()
const main = () =>
either(
msg => msg.startsWith('User cancelled') ? (
msg
) : alert('Link menu')(msg)
)(openLinks)(
bindLR(
showLinksInTaskPaperFrontDoc ? (
taskPaperFrontDocFilePathLR()
) : filePathFromKMVariableLR(
filePathKMVarName
)
)(fp => {
const menuKVs = mdLinkValuesInFile(fp);
return 0 < menuKVs.length ? (
bindLR(
showMenuLR(true)('Links')(
menuKVs.map(x => x.label)
)
)(compose(
Right,
menuChoiceValues(
menuKVs
)('label')('link')
))
) : Left('No links found in document.');
})
);
// ------------ FRONT DOCUMENT IN TASKPAPER ------------
// taskPaperFrontDocFilePathLR :: Either String FilePath
const taskPaperFrontDocFilePathLR = () => {
const
tp = Application('TaskPaper'),
ds = tp.documents;
return 0 < ds.length ? (
Right(ds.at(0).file().toString())
) : Left('No document found in TaskPaper');
};
// ---- FILEPATH GIVEN IN KEYBOARD MAESTRO VARIABLE ----
const filePathFromKMVariableLR = kmVarName => {
const
fp = Application('Keyboard Maestro Engine')
.getvariable(kmVarName);
return Boolean(fp) ? (() => {
const fpPath = filePath(fp);
return doesFileExist(fpPath) ? (
Right(fpPath)
) : Left('No file found at: ' + fpPath);
})() : Left(
'No value found for KM variable: "' + (
kmVarName + '"'
)
);
};
// ----------- CHOICE OF LINKS IN GIVEN FILE -----------
// mdLinkValuesInFile :: FilePath ->
// [{label :: String, link :: String }]
const mdLinkValuesInFile = fp =>
sortBy(comparing(x => x.label))(
lines(readFile(fp)).flatMap(
x => x.includes('](') ? (
parse(mdLinkParse())(strip(x))
) : []
).map(fst)
);
// openLinks :: [URL String] -> IO [URL String]
const openLinks = urls => {
const
sa = Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
});
return urls.map(x => (
sa.openLocation(x),
x
));
};
// menuChoiceValues :: [Dict a] ->
// String -> String -> [String] -> [a]
const menuChoiceValues = menuKVs =>
// A map from a list of keys to a list of values,
// given a list of dictionaries,
// with their label and value keys,
// and some subset of label keys.
labelKey => valueKey => ks => {
const
dct = menuKVs.reduce(
(a, x) => Object.assign(
a, {
[x[labelKey]]: x[valueKey]
}
), {}
);
return ks.flatMap(k => {
const v = dct[k];
return void 0 !== v ? (
[v]
) : [];
});
};
// ------------------- PARSING LINKS -------------------
// mdLinkParse :: () ->
// Parser {title :: String, link :: String}
const mdLinkParse = () =>
bindP(
char('[')
)(_ => bindP(
many(noneOf(']'))
)(title => bindP(
string('](')
)(_ => bindP(
many(noneOf(')'))
)(link => pureP({
label: title.join(''),
link: link.join('')
})))));
// ------------ GENERIC PARSER COMBINATORS -------------
// Parser :: String -> [(a, String)] -> Parser a
const Parser = f =>
// A function lifted into a Parser object.
({
type: 'Parser',
parser: f
});
// altP (<|>) :: Parser a -> Parser a -> Parser a
const altP = p =>
// p, or q if p doesn't match.
q => Parser(s => {
const xs = parse(p)(s);
return 0 < xs.length ? (
xs
) : parse(q)(s);
});
// apP <*> :: Parser (a -> b) -> Parser a -> Parser b
const apP = pf =>
// A new parser obtained by the application
// of a Parser-wrapped function,
// to a Parser-wrapped value.
p => Parser(
s => parse(pf)(s).flatMap(
vr => parse(
fmapP(vr[0])(p)
)(vr[1])
)
);
// bindP (>>=) :: Parser a ->
// (a -> Parser b) -> Parser b
const bindP = p =>
// A new parser obtained by the application of
// a function to a Parser-wrapped value.
// The function must enrich its output, lifting it
// into a new Parser.
// Allows for the nesting of parsers.
f => Parser(
s => parse(p)(s).flatMap(
tpl => parse(f(tpl[0]))(tpl[1])
)
);
// char :: Char -> Parser Char
const char = x =>
// A particular single character.
satisfy(c => x == c);
// fmapP :: (a -> b) -> Parser a -> Parser b
const fmapP = f =>
// A new parser derived by the structure-preserving
// application of f to the value in p.
p => Parser(
s => parse(p)(s).flatMap(
vr => Tuple(f(vr[0]))(vr[1])
)
);
// liftA2P :: (a -> b -> c) ->
// Parser a -> Parser b -> Parser c
const liftA2P = op =>
// The binary function op, lifted
// to a function over two parsers.
p => apP(fmapP(op)(p));
// many :: Parser a -> Parser [a]
const many = p => {
// Zero or more instances of p.
// Lifts a parser for a simple type of value
// to a parser for a list of such values.
const some_p = p =>
liftA2P(
x => xs => [x].concat(xs)
)(p)(many(p));
return Parser(
s => parse(
0 < s.length ? (
altP(some_p(p))(pureP([]))
) : pureP([])
)(s)
);
};
// noneOf :: String -> Parser Char
const noneOf = s =>
// Any character not found in the
// exclusion string.
satisfy(c => !s.includes(c));
// parse :: Parser a -> String -> [(a, String)]
const parse = p =>
// The result of parsing s with p.
s => {
// showLog('s', s)
return p.parser([...s]);
};
// pureP :: a -> Parser a
const pureP = x =>
// The value x lifted, unchanged,
// into the Parser monad.
Parser(s => [Tuple(x)(s)]);
// satisfy :: (Char -> Bool) -> Parser Char
const satisfy = test =>
// Any character for which the
// given predicate returns true.
Parser(
s => 0 < s.length ? (
test(s[0]) ? [
Tuple(s[0])(s.slice(1))
] : []
) : []
);
// sequenceP :: [Parser a] -> Parser [a]
const sequenceP = ps =>
// A single parser for a list of values, derived
// from a list of parsers for single values.
Parser(
s => ps.reduce(
(a, q) => a.flatMap(
vr => parse(q)(snd(vr)).flatMap(
first(xs => fst(vr).concat(xs))
)
),
[Tuple([])(s)]
)
);
// string :: String -> Parser String
const string = s =>
// A particular string.
fmapP(cs => cs.join(''))(
sequenceP([...s].map(char))
);
// ------------------------ 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',
withIcon: sa.pathToResource('TaskPaper.icns', {
inBundle: 'Applications/TaskPaper.app'
})
}),
s
);
};
// ----------------- GENERIC FUNCTIONS -----------------
// https://github.com/RobTrew/prelude-jxa
// Left :: a -> Either a b
const Left = x => ({
type: 'Either',
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: 'Either',
Right: x
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
b => ({
type: 'Tuple',
'0': a,
'1': b,
length: 2
});
// bindLR (>>=) :: Either a ->
// (a -> Either b) -> Either b
const bindLR = m =>
mf => undefined !== m.Left ? (
m
) : mf(m.Right);
// comparing :: (a -> b) -> (a -> a -> Ordering)
const comparing = f =>
x => y => {
const
a = f(x),
b = f(y);
return a < b ? -1 : (a > b ? 1 : 0);
};
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
// A function defined by the right-to-left
// composition of all the functions in fs.
fs.reduce(
(f, g) => x => f(g(x)),
x => x
);
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp)
.stringByStandardizingPath, ref
) && 1 !== ref[0];
};
// 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 => 'Either' === e.type ? (
undefined !== e.Left ? (
fl(e.Left)
) : fr(e.Right)
) : undefined;
// 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);
// first :: (a -> b) -> ((a, c) -> (b, c))
const first = f =>
// A simple function lifted to one which applies
// to a tuple, transforming only its first item.
xy => Tuple(f(xy[0]))(
xy[1]
);
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// lines :: String -> [String]
const lines = s =>
// A list of strings derived from a single
// newline-delimited string.
0 < s.length ? (
s.split(/[\r\n]/)
) : [];
// list :: StringOrArrayLike b => b -> [a]
const list = xs =>
// xs itself, if it is an Array,
// or an Array derived from xs.
Array.isArray(xs) ? (
xs
) : Array.from(xs || []);
// readFile :: FilePath -> IO String
const readFile = fp => {
// The contents of a text file at the
// path file fp.
const
e = $(),
ns = $.NSString
.stringWithContentsOfFileEncodingError(
$(fp).stringByStandardizingPath,
$.NSUTF8StringEncoding,
e
);
return ObjC.unwrap(
ns.isNil() ? (
e.localizedDescription
) : ns
);
};
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(JSON.stringify)
.join(' -> ')
);
// showMenuLR :: Bool -> String -> [String] ->
// Either String [String]
const showMenuLR = blnMult =>
title => xs => 0 < xs.length ? (() => {
const sa = Object.assign(
Application('System Events'), {
includeStandardAdditions: true
});
sa.activate();
const v = sa.chooseFromList(xs, {
withTitle: title,
withPrompt: 'Select' + (
blnMult ? (
' one or more of ' +
xs.length.toString()
) : ':'
),
defaultItems: xs[0],
okButtonName: 'OK',
cancelButtonName: 'Cancel',
multipleSelectionsAllowed: blnMult,
emptySelectionAllowed: false
});
return Array.isArray(v) ? (
Right(v)
) : Left('User cancelled ' + title + ' menu.');
})() : Left(title + ': No items to choose from.');
// snd :: (a, b) -> b
const snd = tpl =>
// Second member of a pair.
tpl[1];
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = f =>
xs => list(xs).slice()
.sort((a, b) => f(a)(b));
// strip :: String -> String
const strip = s =>
s.trim();
// MAIN ---
return main();
})();