The _note attribute seen in some OPML files is not a part of the OPML 1.0 or 2.0 standards – it was a proprietary innovation of Omni – but it can be used by Scrivener, among other applications:
Scrivener > Settings > Sharing > OPML
Bike is a deep and flexible outliner, in which your whole text is outlinable, and notes can be represented with the (Format > Row > Note) note type.
Some other tools use a more shallow and opinionated design, in which outlining is limited to something more like headers, or titles of topics, and the bulk of your text consists of flat, un-outlined (and un-outlinable) inline notes.
Because of this deep (everything is outlinable) approach Bike doesn’t automatically export note rows to the non-standard _note row, where they would become flattened, non-outline text – frozen and beyond the scope of further outlining.
But if you would like a specialised Save As, for use with Scrivener etc, Bike is highly flexible, and offers more than one way of specialising imports and exports with scripts.
Here is a first rough draft of a JavaScript for Automation Save As OPML with _note attributes.
Expand disclosure triangle to view JS source
(() => {
"use strict";
// Save As OPML with text of 'note' rows folded into
// an Omni-style `_note` attribute, for Scrivener etc.
// Rob Trew @2026
// Ver 0.1
// Adjust options below.
// If nestedNotesIndented = false, descendants of
// Bike rows which have type="note" will all be full left.
// If nestedNotesIndented = true, their nesting level will
// be shown with indents in the _note attribute.
// the indent string will be given by options.indent.
const options = {
title: "Save as OPML with _note attribute",
defaultFilePath: "~/Desktop/opmlWithNotes.opml",
nestedNotesIndented: false,
indent: " "
};
// For Scrivener options, see:
// https://www.literatureandlatte.com/blog/import-and-export-opml-outliner-files-to-scrivener
// Saves EITHER
// selected rows and their descendents,
// (if selection is of *block* type),
// OR (if selection is of *caret* type)
// all rows in current focus.
// Prompts user for a Save As path.
// main :: IO ()
const main = () =>
either(
alert(options.title)
)(
notify(options.title)("Saved to:")
)(
bindLR(
JSON.parse(
Application("Bike").evaluate({
script,
input: JSON.stringify(options)
})
)
)(
opml => bindLR(
confirmSavePathLR(options.defaultFilePath)
)(
fp => writeFileLR(fp)(opml)
)
)
);
const script = (opts => {
const options = JSON.parse(opts);
const bikeMain = () => {
const editor = bike.frontmostOutlineEditor;
return JSON.stringify(
fmapLR(
([focus, selection]) => forestAsOPML(
// All block-selected rows, if any,
// otherwise, all rows in focus.
(
"block" === selection.type
? selection.coverRows
: focus.parent
? [focus]
: focus.children
)
.map(withFoldedNotes(options.nestedNotesIndented))
)
)(
editor
? Right([editor.focus, editor.selection])
: Left("No document open in Bike.")
), null, 2
);
};
// withFoldedNotes :: Bike Row -> Bool -> Tree {text::String, _note::String}
const withFoldedNotes = notesIndented =>
row => {
const go = x => {
const
[notes, children] = partition(
v => "note" === v.type
)(
x.children
),
base = 1 + x.level;
const noteLine = withIndent =>
r => withIndent
? `${(" ").repeat(r.level - base)}${r.text.string}`
: r.text.string
return Node({
text: x.text.string,
_note: notes.map(
note => [note.text.string].concat(
note.descendants.map(
noteLine(notesIndented)
)
).join("\n")
).join("\n")
})(
children.map(go)
);
};
return go(row);
};
// forestAsOPML :: [Tree Dict] -> OPML String
const forestAsOPML = forest => {
const opmlOutline = level =>
tree => {
const
dict = root(tree),
xs = nest(tree),
indent = (" ").repeat(level),
prefix = `${indent}<outline`;
return 0 < xs.length
? [
`${prefix} ${attrs(dict)}>`,
xs.flatMap(opmlOutline(1 + level)).join("\n"),
`${indent}</outline>`
]
: [`${prefix} ${attrs(dict)}/>`];
};
// attrs :: Dict -> XML Attributes String
const attrs = dct =>
Object.entries(dct).flatMap(
([k, v]) => v || ("text" === k)
? [`${k}="${xmlEscaped(v)}"`]
: []
)
.join(" ")
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<opml version="2.0">',
' <head>',
' <meta charset="utf-8" />',
' </head>',
' <body>',
forest.flatMap(opmlOutline(1)).join("\n"),
' </body>',
'</opml>'
]
.join("\n");
};
// xmlEscaped :: String -> String
const xmlEscaped = s =>
(
typeof s !== "string"
? String(s || "")
: s
)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g, '')
.replace(/[&<>"'\n\r\t]/g, m => {
switch (m) {
case '&': return '&';
case '<': return '<';
case '>': return '>';
case '"': return '"';
case "'": return ''';
// Preserve whitespace formatting in attributes
case '\n': return ' ';
case '\r': return ' ';
case '\t': return '	';
default: return match;
}
});
// -------------- GENERICS FOR BIKE --------------
// 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
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
// A pair of values, possibly of
// different types.
b => ({
type: "Tuple",
"0": a,
"1": b,
length: 2,
*[Symbol.iterator]() {
for (const k in this) {
if (!isNaN(k)) {
yield this[k];
}
}
}
});
// flip :: (a -> b -> c) -> b -> a -> c
const flip = op =>
// The binary function op with
// its arguments reversed.
1 !== op.length
? (a, b) => op(b, a)
: (a => b => op(b)(a));
// fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
const fmapLR = f =>
// Either f mapped into the contents of any Right
// value in e, or e unchanged if is a Left value.
e => "Left" in e
? e
: Right(f(e.Right));
// nest :: Tree a -> [a]
const nest = tree =>
tree.nest;
// root :: Tree a -> a
const root = tree =>
// The value attached to a tree node.
tree.root;
// partition :: (a -> Bool) -> [a] -> ([a], [a])
const partition = p =>
// A tuple of two lists - those elements in
// xs which match p, and those which do not.
xs => {
const [matches, nons] = [[], []];
return (
xs.forEach(
x => (
p(x)
? matches
: nons
).push(x)
),
Tuple(matches)(nons)
);
};
// ---
return bikeMain();
})
.toString();
// ----------------------- 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
);
};
// confirmSavePathLR :: FilePath -> Either Message FilePath
const confirmSavePathLR = fp => {
const
sa = Object.assign(Application.currentApplication(), {
includeStandardAdditions: true
}),
pathName = splitFileName(fp),
fldr = pathName[0];
sa.activate();
try {
return Right(
sa.chooseFileName({
withPrompt: "Save As:",
defaultName: pathName[1],
defaultLocation: Path(ObjC.unwrap(
$(doesDirectoryExist(fldr) ? (
fldr
) : "~")
.stringByExpandingTildeInPath
))
})
.toString()
);
} catch (e) {
return Left(e.message);
}
};
// doesDirectoryExist :: FilePath -> IO Bool
const doesDirectoryExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp).stringByStandardizingPath,
ref
) && ref[0];
};
// notify :: String -> String -> String ->
// String -> IO ()
const notify = withTitle =>
subtitle => message =>
Object.assign(
Application.currentApplication(),
{ includeStandardAdditions: true }
)
.displayNotification(
message,
{
withTitle,
subtitle
}
);
// splitFileName :: FilePath -> (String, String)
const splitFileName = strPath =>
// Tuple of directory and file name,
// derived from file path. (Inverse of combine).
("" !== strPath) ? (
("/" !== strPath[strPath.length - 1]) ? (() => {
const
xs = strPath.split("/"),
stem = xs.slice(0, -1);
return stem.length > 0 ? (
Tuple(
`${stem.join("/")}/`
)(xs.slice(-1)[0])
) : Tuple("./")(xs.slice(-1)[0]);
})() : Tuple(strPath)("")
) : Tuple("./")("");
// writeFileLR :: FilePath ->
// String -> Either String IO FilePath
const writeFileLR = fp =>
// Either a message or the filepath
// to which the string has been written.
s => {
const
e = $(),
efp = $(fp).stringByStandardizingPath;
return $.NSString.alloc
.initWithUTF8String(s)
.writeToFileAtomicallyEncodingError(
efp, false,
$.NSUTF8StringEncoding, e
)
? Right(ObjC.unwrap(efp))
: Left(ObjC.unwrap(e.localizedDescription));
};
// -------------- GENERICS FOR CONTEXT ---------------
// 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 =>
// A pair of values, possibly of
// different types.
b => ({
type: "Tuple",
"0": a,
"1": b,
length: 2,
*[Symbol.iterator]() {
for (const k in this) {
if (!isNaN(k)) {
yield this[k];
}
}
}
});
// 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);
// 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);
// fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
const fmapLR = f =>
// Either f mapped into the contents of any Right
// value in e, or e unchanged if is a Left value.
e => "Left" in e
? e
: Right(f(e.Right));
// --------------------- LOGGING ---------------------
// showLog :: a -> IO ()
const showLog = (...args) =>
// eslint-disable-next-line no-console
console.log(
args
.map(JSON.stringify)
.join(" -> ")
);
// sj :: a -> String
const sj = (...args) =>
// Abbreviation of showJSON for quick testing.
// Default indent size is two, which can be
// overriden by any integer supplied as the
// first argument of more than one.
JSON.stringify.apply(
null,
1 < args.length && !isNaN(args[0])
? [args[1], null, args[0]]
: [args[0], null, 2]
);
return main();
})();
Testing
Making sure that you copy all of the code above (easy to drop a few lines
), you can paste it into Script Editor, and set the language selector at top left to JavaScript (rather than AppleScript).
With your Bike selection in an outline in which some child rows have the type note, you should find that
- in Block mode, the script will export the selected rows and their descendants
- in Caret mode, the script will export the whole of the current focus (or whole document if not focused)
Adjusting
At the top of the script you can try editing the values of the keys in this dictionary:
const options = {
title: "Save as OPML with _note attribute",
defaultFilePath: "~/Desktop/opmlWithNotes.opml",
nestedNotesIndented: false,
indent: " "
};
By default, the text of any child row of type note will be folded up with that of all its descendants, and attached, in a _note attribute to the parent row.
(if you want a row to have notes – don’t make them following siblings of that row, make them children indented under it)
If the option nestedNotesIndented is false, additional and descendant notes of a row will all become additional lines, aligned full left.
If you are outlining the notes and sub-notes themselves, and set nestedNotesIndented to true, then the string specified by options.indent will be used to indent accordingly.
Assigning to a shortcut
You can assign an osascript like this to a shortcut key in various ways, including with Keyboard Maestro and FastScripts 3.
Understanding
Remember that if you create a specialised OPML file like this, and reopen it in Bike, your note rows will appear to have disappeared. Actually they are still there as hidden _note attributes of their parent row. Present and correct, in a sense, but no longer outlineable – just static rows of text.
Let me know if you feel the need for a script that moves in the opposite direction – liberating _note attributes (perhaps in OPML coming in from other tools) and turning them into full outlinable sequences of note rows.
