Not sure whether others are using anything corresponding to footnotes in their Bike documents, but here is a draft script, for testing, which aims to make it easier to work with internal links, as in Format > Add Link to Row
(⌥⌘K)
Its easy enough to click a link which takes us to another row (perhaps, conceptually, analogous to a traditional footnote),
but how do we jump back from the footnote row, to the row which contains the link ?
@jessegrosjean pointed out to me that the trick (in a script) is to let an Outline Path find rows which link to the selected row.
Expand disclosure triangle to view an example of JavaScript path use
const
targetID = targetRow.id(),
matches = doc.query({
outlinePath: (
`//*/run::@link endswith "#${targetID}"/..`
)
});
Here, then, is a draft script which aims to:
- Jump back from the selected line to a row which contains a link to it, or
- if the selected line itself contains an internal link, then jump to the target of its link, or
- if it contains multiple links to other rows, then chose the next link (cycling through them, remembering where it jumped last time), or
- Just show a notification if the selected row is neither linked to, nor a container of links.
The idea, in short, is that if you bind a key like ⌘J to this script, you should just be able to tap it repeatedly to move back and forth between footnotes and the rows which they elaborate or reference evidence / authority for.
For memory of which link (in a multiply linked line) was followed last, the script aims to:
- Use a Keyboard Maestro variable, if Keyboard Maestro is installed on your system, and otherwise
- use a .plist file in the same folder as the script.
Source – note that KM11 requires a return
prefix at the start of a script, as in return (() => {
Expand disclosure triangle to view JS source
(() => {
"use strict";
// Jump from and to internal links in the front
// https://www.hogbaysoftware.com/bike/
// document.
// Where a line contains links to more than one
// other point in the document, the script
// cycles back and forth between the various links.
// Tested only with Bike 1.18.2 (Preview 176)
// -------- JAVASCRIPT FOR AUTOMATION SCRIPT ---------
// Rob Trew @2024
// Ver 0.01
ObjC.import("AppKit");
const
// Used if Keyboard Maestro is installed
// on the system.
// If not, when memory of state is required,
// a .plist file in the same folder as the script
// is used.
keyboardMaestroEngineID = (
"com.stairways.keyboardmaestro.engine"
),
// Used either for a Keyboard Maestro variable name
// bound to a JSON value, or for the name of a
// plist dictionary file.
stateName = "rowLinksQueueState";
// External state is used only if a selected row
// happens to contain more than one internal link.
// It allows us to toggle-cycle through the links
// with repeated calls to this script.
// ---------------------- MAIN -----------------------
const main = () => {
const
bike = Application("Bike"),
frontDoc = bike.documents.at(0);
return ((
bike.activate(),
either(
notify(
"Jump to and from internal links"
)("")("Pop")
)(
report => report
)(
bindLR(
frontDoc.exists()
? Right(frontDoc.selectionRow())
: Left("No document open in Bike.")
)(
jumpToOrFromInternalLinkLR(stateName)(
frontDoc
)
)
)
));
};
// jumpToOrFromInternalLinkLR :: Bike Document ->
// Bike Row -> Either String String
const jumpToOrFromInternalLinkLR = memoryName =>
doc => row => either(
() => bindLR(
internalLinksInGivenRowLR(doc)(row)
)(
// 'Out' to the next internal
// link target in this row
nextLinkFollowedLR(memoryName)(doc)(row)
)
)(
// 'Back to' the first row with
// a link to the selected line.
linkSourceRows => Right(
jumpedToRow(doc)(
linkSourceRows[0]
)
)
)(
rowsLinkingToTargetRowLR(doc)(row)
);
// nextLinkFollowed :: String -> Bike Document ->
// Bike Row -> [URL String] -> IO Either String String
const nextLinkFollowedLR = memoryName =>
// 'Out from' next link found
// in this row.
doc => row => rowLinks =>
linkFollowedWithinDocLR(doc)(
rowLinks[(
nextLinkIndex(memoryName)(
doc
)(rowLinks.length)(row)
)]
);
// nextLinkIndex :: String -> Bike Doc ->
// Int -> Bike Row -> Int
const nextLinkIndex = memoryName =>
doc => nLinks => bikeRow => {
const
memoryDict = stateRetrieved(memoryName),
rowID = bikeRow.id(),
sameDoc = doc.id() in memoryDict,
// First link, or next where there is
// memory of a preceding link choice.
nextIndex = 1 < nLinks
? sameDoc && (
rowID in memoryDict
)
? (1 + memoryDict[rowID]) % nLinks
: 0
: 0;
return (
stateStored(sameDoc)(memoryName)({
[doc.id()]: true,
[rowID]: nextIndex
}),
nextIndex
);
};
// ---------------------- BIKE -----------------------
// internalLinksInGivenRowLR :: Bike Doc ->
// Bike Row -> Either String [Bike URL]
const internalLinksInGivenRowLR = doc =>
targetRow => {
const
docID = doc.id(),
linksToRows = linksInRow(doc)(targetRow)
.filter(
link => link.includes(docID)
);
return 0 < linksToRows.length
? Right(linksToRows)
: Left(
"No links to other rows in selected line."
);
};
// bikeURLFocusAndRowIDsLR :: Bike URL ->
// Either String (String, String)
const bikeURLFocusAndRowIDsLR = bikeURL => {
const
idPair = bikeURL
.split(/\//u)
.slice(-1)[0]
.split(/#/u);
return 2 === idPair.length
? Right(idPair)
: Left(`Unexpected URL pattern: "${bikeURL}"`);
};
// linkFollowedWithinDocLR :: Bike Doc ->
// Bike Row -> IO Either String String
const linkFollowedWithinDocLR = doc =>
bikeURL => bindLR(
bikeURLFocusAndRowIDsLR(bikeURL)
)(
([focusID, rowID]) => {
const
rows = doc.rows,
focusRow = rows.where({id: focusID}).at(0),
targetRow = rows.where({id: rowID}).at(0);
return (
// Effect
focusRow.exists() && (
doc.focusedRow = focusRow
),
targetRow.exists()
? (
// Effect
doc.select({at: targetRow}),
// Value
Right(`(${rowID}) '${targetRow.name()}'`)
)
: Left(`Row not found by id: '${rowID}'`)
);
}
);
// linksInRow :: Bike Doc ->
// Bike Row -> [Bike URL]
const linksInRow = doc =>
row => linksInHTML(
doc.export({
from: row,
as: "bike format",
all: false
})
);
// jumpedToRow :: Bike Document -> Bike Row -> IO String
const jumpedToRow = doc =>
row => (
doc.focusedRow = row.containerRow(),
doc.select({at: row}),
`Selected: (${row.id()}) "${row.name()}"`
);
// rowsLinkingToTargetRowLR :: Bike Document ->
// Bike Row -> IO Either String Bike Rows
const rowsLinkingToTargetRowLR = doc =>
targetRow => {
const
targetID = targetRow.id(),
matches = doc.query({
outlinePath: (
`//*/run::@link endswith "#${targetID}"/..`
)
});
return 0 < matches.length
? Right(matches)
: Left(
[
"No links point to row:",
`(${targetID}) "${targetRow.name()}"`
]
.join("\n")
);
};
// ---------------- PERSISTENT STATE -----------------
// stateRetrieved :: String -> IO Dict
const stateRetrieved = storeName =>
// Either an empty dictionary, or, if found,
// a dictionary retrieved from JSON in a named
// Keyboard Maestro variable (if KM is installed)
// or otherwise from a ${storeName}.plist file in the
// same folder as this script.
(
appIsInstalled(
keyboardMaestroEngineID
)
? dictFromKMVar
: dictFromPlist
)(storeName);
// stateStored :: Boolean -> String ->
// Dict -> IO Dict
const stateStored = mergedWithExisting =>
// The entries of the given dictionary either
// (merged with/added to) a JSON string in a named
// Keyboard Maestro variable (if KM is installed)
// or (merged with/added to) a ${storeName}.plist
// file in the same folder as this script.
(
appIsInstalled(
keyboardMaestroEngineID
)
? dictToKMVar
: dictToPlist
)(mergedWithExisting);
// dictFromKMVar :: String -> Dict
const dictFromKMVar = storeName =>
either(() => ({}))(
dict => dict
)(
jsonParseLR(
Application("Keyboard Maestro Engine")
.getvariable(storeName)
)
);
// dictFromPlist :: String -> Dict
const dictFromPlist = storeName =>
either(() => ({}))(
dict => dict
)(
readPlistFileLR(
combine(
Object.assign(
Application.currentApplication(),
{includeStandardAdditions: true}
)
.doShellScript("pwd")
)(`${storeName}.plist`)
)
);
// dictToKMVar :: Boolean -> String ->
// Dict -> IO String
const dictToKMVar = mergedWithExisting =>
storeName => dict => {
const kme = Application("Keyboard Maestro Engine");
return (
kme.setvariable(
storeName,
{
to: JSON.stringify(
either(() => ({}))(
existingDict => Object
.assign(
existingDict, dict
)
)(
jsonParseLR(
mergedWithExisting
? kme.getvariable(
storeName
)
: "{}"
)
),
null, 2
)
}
),
kme.getvariable(storeName)
);
};
// dictToPlist :: Boolean -> String -> String ->
// Dict -> Either String Dict
const dictToPlist = mergedWithExisting =>
// Either a string or key-value pair written to
// a .plist dictionary in the same directory as
// the current script.
fileBaseName => dictKVs => {
const
fpState = combine(
Object.assign(
Application.currentApplication(),
{includeStandardAdditions: true}
)
.doShellScript("pwd")
)(
`${fileBaseName}.plist`
);
return (
// ----------------- EFFECT ------------------
writePlist(
mergedWithExisting
? Object.assign(
readPlistFileLR(fpState)
.Right || {},
dictKVs
)
: dictKVs
)(fpState),
// ---------------- VALUE ----------------
readPlistFileLR(fpState)
);
};
// ----------------------- JXA -----------------------
// appIsInstalled :: String -> Bool
const appIsInstalled = bundleID =>
Boolean(
$.NSWorkspace.sharedWorkspace
.URLForApplicationWithBundleIdentifier(
bundleID
)
.fileSystemRepresentation
);
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager
.defaultManager
.fileExistsAtPathIsDirectory(
$(fp).stringByStandardizingPath,
ref
) && !ref[0];
};
// linksInHTML :: HTML String -> [URL]
const linksInHTML = html =>
// A (possibly empty) list of URLs.
bindLR(
parseFromHTML(html)
)(
compose(
map(x => x.attributes.href),
filterTree(x => "a" === x.name)
)
);
// notify :: String -> String -> String ->
// String -> IO ()
const notify = withTitle =>
subtitle => soundName => message =>
Object.assign(
Application.currentApplication(),
{includeStandardAdditions: true}
)
.displayNotification(
message,
{
withTitle,
subtitle,
soundName
}
);
// parseFromHTML :: String -> Either String Tree Dict
const parseFromHTML = html => {
const
error = $(),
node = $.NSXMLDocument.alloc
.initWithXMLStringOptionsError(
html, 0, error
);
return node.isNil()
? Left(`Not parseable as XML: ${html}`)
: Right(xmlNodeDict(node));
};
// readPlistFileLR :: FilePath -> Either String Dict
const readPlistFileLR = fp =>
// Either a message or a dictionary of key-value
// pairs read from the given file path.
bindLR(
doesFileExist(fp)
? Right(filePath(fp))
: Left(`No file found at path:\n\t${fp}`)
)(
fpFull => {
const
e = $(),
maybeDict = $.NSDictionary
.dictionaryWithContentsOfURLError(
$.NSURL.fileURLWithPath(fpFull),
e
);
return maybeDict.isNil()
? (() => {
const
msg = ObjC.unwrap(
e.localizedDescription
);
return Left(`readPlistFileLR:\n\t${msg}`);
})()
: Right(ObjC.deepUnwrap(maybeDict));
}
);
// writePlist :: Dict -> FilePath -> Either String IO ()
const writePlist = dict =>
// A dictionary of key value pairs
// written to the given file path.
fp => $(dict)
.writeToFileAtomically(
$(fp).stringByStandardizingPath, true
);
// xmlNodeDict :: NSXMLNode -> Tree Dict
const xmlNodeDict = xmlNode => {
// A Tree of dictionaries derived from an NSXMLNode
// in which the keys are:
// name (tag), content (text), attributes (array)
// and XML (source).
const
uw = ObjC.unwrap,
hasChildren = 0 < parseInt(
xmlNode.childCount, 10
);
return Node({
name: uw(xmlNode.name),
content: hasChildren
? undefined
: (uw(xmlNode.stringValue) || " "),
attributes: (() => {
const attrs = uw(xmlNode.attributes);
return Array.isArray(attrs)
? attrs.reduce(
(a, x) => Object.assign(a, {
[uw(x.name)]: uw(
x.stringValue
)
}),
{}
)
: {};
})(),
xml: uw(xmlNode.XMLString)
})(
hasChildren
? uw(xmlNode.children).map(xmlNodeDict)
: []
);
};
// --------------------- GENERIC ---------------------
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: x
});
// Node :: a -> [Tree a] -> Tree a
const Node = v =>
// Constructor for a Tree node which connects a
// value of some kind to a list of zero or
// more child trees.
xs => ({
type: "Node",
root: v,
nest: xs || []
});
// 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);
// combine (</>) :: FilePath -> FilePath -> FilePath
const combine = fp =>
// The concatenation of two filePath segments,
// without omission or duplication of "/".
fp1 => Boolean(fp) && Boolean(fp1)
? "/" === fp1.slice(0, 1)
? fp1
: "/" === fp.slice(-1)
? fp + fp1
: `${fp}/${fp1}`
: (fp + fp1);
// 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
);
// 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
);
// filterTree (a -> Bool) -> Tree a -> [a]
const filterTree = p =>
// List of all values in the tree
// which match the predicate p.
foldTree(x => xs =>
p(x)
? [x, ...xs.flat()]
: xs.flat()
);
// foldTree :: (a -> [b] -> b) -> Tree a -> b
const foldTree = f => {
// The catamorphism on trees. A summary
// value obtained by a depth-first fold.
const go = tree => f(
tree.root
)(
tree.nest.map(go)
);
return go;
};
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(
[
e.message,
`(line:${e.line} col:${e.column})`
].join("\n")
);
}
};
// map :: (a -> b) -> [a] -> [b]
const map = f =>
// The list obtained by applying f
// to each element of xs.
// (The image of xs under f).
xs => [...xs].map(f);
// MAIN ---
return main();
})();
Keyboard Maestro macro, binding the script to ⌘J
BIKE - JUMP back and forth between internal link and the targeted row.kmmacros.zip (18.6 KB)
“Aims to” is the key here – no guarantees at all. Please remember to back data up and use a dummy document for testing.