With some limitations (scale, attributes, single file) I find the Kosshi iOS / iPadOS Outliner quite useful for doing some work on a Bike-authored outline while away from macOS.
Kosshi has a simple macOS version which automatically syncs its SQLite database – a single file – with the iOS / iPadOS version.
- When I want to be able to work on a Bike outline away from my desk I copy it and paste it into macOS Kosshi, which then syncs to iOS.
- When I’m done, I copy the (modified and synced) outline from Kosshi, paste back into Bike, and delete from Kosshi.
Limitations of this approach
- Scale – When I pasted Moby Dick into macOS kosshi, it did, in time, successfully sync to iOS, but the time was longish, and until the
was fully arrived the iOS app kept vanishing. Outlines on a sub-cetacean scale seem to sync well and promptly. - Attributes Kosshi knows nothing of Bike attributes and doesn’t preserve them. It knows about task completion status (
true⇄false), but not about task completion date-times, which it drops. - Inline formats and row types. Kosshi does parse and render inline Markdown formatting, and Markdown code and quote blocks, but it doesn’t write the markup out to file in its default OPML export. Kosshi doesn’t know about
headingsornotes.
To improve the preservation of inline formats and row types in the Bike ⇄ Kosshi mapping, I use a couple of Copy As scripts, which I am personally finding useful, but for which I make no guarantees of any kind (MIT License). Test on dummy data, and make sure you have understood what the two Copy scripts are doing.
They aim to preserve:
- inline formats and links,
- code and quote blocks,
- completed and uncompleted tasks,
- ordered and unordered lists,
- horizontal rules
To test either of the scripts below:
- Click expansion triangle and copy the whole script, using the copy icon at top right of the code field
- Paste into Script Editor, and set the language selector at top left to
JavaScript - With Bike or Kosshi open, use the
runbutton in Script Editor.
You can assign a script to a hotkey with a utility like Keyboard Maestro or FastScripts.
More generally, see Using Scripts | Bike
COPY FROM BIKE FOR PASTING IN KOSSHI.APP
Expand disclosure triangle to view JS source
(() => {
"use strict";
ObjC.import("AppKit");
// ---- COPY FROM BIKE FOR PASTING IN KOSSHI.APP -----
//
// Rob Trew @2026
// Ver 0.02
//
// Creates an `app.kosshi.outline-rows` pasteboard
// aiming to map as much as possible of Bike row types,
// and inline formats.
// The idea of pasting into the macOS Kosshi.app is to
// is to be able to use an outline in the iOS Kosshi
// application.
// (Koshi automatically syncs to iOS)
// -------------------- BEHAVIOUR --------------------
// Which rows are copied ?
// In block mode, selected blocks and all their visible
// descendants, otherwise, all visible rows.
// What formatting is preserved ?
// Inline:
// Inline formats are represented as Markdown, which
// Kosshi parses and renders.
// Row types:
// Kosshi lacks fine-grained row types, but
// 1. Contiguous sequences of "code" or "quote" type
// rows in Bike become CodeBlocks or BlockQuotes
// in Kosshi.
// 2. Task rows, and their completion status
// but not date, are represented in Koshi
// with [] or [x] prefixes
// 3. Ordered lists become numbered in Kosshi
// 4. Unordered lists are mapped to Kosshi's "non-prose"
// row type, and so displayed with bullets.
// 5. Kosshi doesn't model horizontal rules, so these
// are just copied as rows with the text "---"
// main :: IO ()
const main = () => {
const bike = Application("Bike");
return 0 < bike.documents.length
? (
dicts => (
setClipOfTextType("app.kosshi.outline-rows")(
`{"rows": ${dicts}`
),
(n => {
const affix = 1 === n ? "" : "s";
return `Copied ${n} row${affix} from Bike for pasting into Kosshi.`
})(JSON.parse(dicts).length)
)
)(
bike.evaluate({ script })
)
: "No documents open in Bike";
};
// script :: Source String for Bike JSContext
const script = (() => {
// bikeMain :: Bike IO () -> String
const bikeMain = () =>
blockTypeFormats(
groupOnKey(x => x.type)(
forestPreorder(
orderedNodesNumbered(
visibleForestOrBlockSelectionAsTreeTuples(
kosshiRowDict
)(
bike.frontmostOutlineEditor
)
)
)
)
);
// -------------------- BIKE ---------------------
const allCode = /^`[^`]+`$/u;
// orderedNodesNumbered :: [Tree Dict] -> [Tree Dict]
const orderedNodesNumbered = forest => {
const go = xs =>
groupOnKey(x => root(x).type)(xs)
.flatMap(
([k, trees]) => "ordered" === k
? trees.map(
(kv, i) => bimap(
dict => ({
...dict,
content: `${1 + i}. ${dict.content}`
})
)(go)(kv)
)
: trees.map(second(go))
);
return go(forest);
};
// blockTypeFormats :: [(String, [Dict])] -> [Dict]
const blockTypeFormats = taggedGroups =>
taggedGroups.flatMap(
([tag, dicts]) => (
({
// "body": xs => xs,
// "heading": xs => xs.map(dict => ({ ...dict, bookmarked: true })),
"quote": blockFormat(
xs => `> ${xs.join("\n> ")}`
)(x => `> ${x}`),
"code": blockFormat(
xs => "```\n" + xs.join("\n") + "\n```"
)(x => `\`${x}\``),
// "note": xs => xs,
// "unordered": xs => xs,
// "ordered": xs => xs,
"task": xs => xs.map(x => ({
...x,
content: x.completed
? `[x] ${x.content}`
: `[] ${x.content}`
})),
"hr": xs => xs.map(
x => ({ ...x, content: "---" })
)
})[tag] || (xs => xs)
)(dicts)
);
// blockFormat :: ([String] -> String) -> [Dict] -> [Dict]
const blockFormat = fCombinedRows =>
fSingleRow => xs => 1 < xs.length
? groupOn(x => x.level)(xs).flatMap(
levelRows => 1 < levelRows.length
? {
...levelRows[0],
content: fCombinedRows(
levelRows.map(x => x.content)
)
}
: levelRows.map(
x => ({ ...x, content: fSingleRow(x.content) })
)
)
: xs.map(
x => ({ ...x, content: fSingleRow(x.content) })
);
// kosshiRowDict :: (Bike Row, Int) -> Dict
const kosshiRowDict = (row, topLevel) => {
const
md = row.text.toMarkdown(),
type = row.type,
isFormattedAsCode = "body" === type && allCode.test(md);
return {
level: row.level - topLevel,
completed: !!row.getAttribute("done", "date"),
content: isFormattedAsCode
? md.slice(1, -1)
: md,
type: isFormattedAsCode
? "code"
: type,
prose: "unordered" !== type
};
};
// visibleForestOrBlockSelectionAsTreeTuples :: (Bike Row -> a) ->
// Bike Editor -> [Tree a]
const visibleForestOrBlockSelectionAsTreeTuples = fRowValue =>
ed => {
const
{ selection, focus } = ed,
parentRows = "block" === selection.type
? selection.coverRows
: focus.parent ? [focus] : focus.children,
topLevel = Math.min(...parentRows.map(row => row.level));
const go = rows =>
rows.flatMap(
row => ed.isFocused(row) && (row.text.count || row.firstChild)
? [[
fRowValue(row, topLevel),
go(row.children)
]]
: []
);
return go(parentRows);
};
// -------------- GENERIC FOR BIKE ---------------
// bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
const bimap = f =>
// Tuple instance of bimap.
// A tuple of the application of f and g to the
// first and second values respectively.
g => ([a, b]) => [f(a), g(b)];
// forestPreorder :: [Tree a] -> [a]
const forestPreorder = forest => {
const go = ([v, xs]) =>
[v].concat(xs.flatMap(go));
return forest.flatMap(go);
};
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = eqOp =>
// A list of lists, each containing only elements
// equal under the given equality operator, such
// that the concatenation of these lists is xs.
// (Alternative – imperative – implementation)
xs => {
if (0 === xs.length) return [];
const acc = [];
let grp = [xs[0]];
for (let i = 1; i < xs.length; i++) {
const x = xs[i];
// Compared against head of current group.
if (eqOp(grp[0])(x)) {
grp.push(x);
} else {
// One group completed, another started.
acc.push(grp);
grp = [x];
}
}
acc.push(grp);
return acc;
};
// groupOn :: (a -> b) -> [a] -> [[a]]
const groupOn = f =>
// A list of lists, each containing only elements
// which return equal values for f,
// such that the concatenation of these lists is xs.
xs => 0 < xs.length
? groupBy(
a => b => a[0] === b[0]
)(
xs.map(x => [f(x), x])
)
.map(gp => gp.map(ab => ab[1]))
: [];
// groupOnKey :: Eq k => (a -> k) -> [a] -> [(k, [a])]
const groupOnKey = f =>
// A list of (k, [a]) tuples, in which each [a]
// contains only elements for which f returns the
// same value, and in which k is that value.
// The concatenation of the [a] in each tuple === xs.
xs => 0 < xs.length
? groupBy(a => b => a[0] === b[0])(
xs.map(x => [f(x), x])
)
.map(gp => [
gp[0][0],
gp.map(ab => ab[1])
])
: [];
// nest :: Tree a -> [a]
const nest = tree => tree[1];
// root :: Tree a -> a
const root = tree => tree[0];
// second :: (a -> b) -> ((c, a) -> (c, b))
const second = f =>
// A function over a simple value lifted
// to a function over a tuple, with f
// applied to the second term.
// f (a, b) -> (a, f(b))
([x, y]) => [x, f(y)];
// BIKE MAIN ---
return JSON.stringify(bikeMain(), null, 2);
})
.toString()
// ----------------------- JXA -----------------------
// setClipOfTextType :: String -> String -> IO String
const setClipOfTextType = utiOrBundleID =>
txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(txt),
utiOrBundleID
),
txt
);
};
return main();
})();
COPY FROM KOSSHI.APP FOR PASTING IN BIKE
Expand disclosure triangle to view JS source
(() => {
"use strict";
ObjC.import("AppKit");
// ---- COPY FROM KOSSHI.APP FOR PASTING IN BIKE -----
//
// Rob Trew @2026
// Ver 0.02
//
//
// Creates a `public.data.xml.opml` pasteboard
// aiming to map as much as inline and row type,
// formatting as possible from Kosshi to Bike 2.0
// The idea of interaction between Bike and Kosshi.app
// is to be able to work on Bike outlines in iOS.
// (Koshi automatically syncs to iOS)
// -------------------- BEHAVIOUR --------------------
// Which rows are copied from Koshi ?
// All rows in the Koshi database are copied.
// This script is best suited to a workflow which
// mainly keeps Kosshi empty, only pasting into it
// whatever you immediately want to work on, or
// refer to, in iOS or iPadOS.
// What formatting is preserved ?
// Inline:
// Inline formats are represented as Markdown
// markup, which Bike can read from OPML row text.
// Row types:
// Kosshi lacks fine-grained row types, but
// 1. CodeBlocks or BlockQuotes in Kosshi become
// sequences of contiguous code or blockquote
// rows in Bike
// 2. Task rows, and their completion status,
// are mapped to Bike task rows. Kosshi does not
// record a date-time for completion, so the moment
// of the copy is used.
// 3. Ordered lists are mapped to sequences of Bike
// ordered rows
// 4. "Non-prose" (i.e. bulleted – see ⇧P toggle) rows
// in Kosshi that are not tasks, ordered, code or quote
// are mapped to 'unordered' list rows in Bike.
// 5. Any row containing just "---" or "***" is mapped,
// in the clipboard for Bike, to a horizontal rule.
// main :: IO ()
const main = () => {
const
rootID = "ROOT",
fp = filePath("~/Library/Containers/app.kosshi/Data/Library/Application Support/Kosshi/Kosshi.sqlite"),
cmd = `/usr/bin/sqlite3 ${fp.replaceAll(" ", "\\ ")} -json '${koshiRowSQL}'`;
return either(
alert("Kosshi outline as OPML")
)(
([n, opml]) => (
setClipOfTextType("public.data.xml.opml")(opml),
`Copied ${n} row${1 === n ? "" : "s"} from Koshi (for pasting to Bike)`
)
)(
fmapLR(
dicts => [
dicts.length,
compose(
bikeOPMLFromForest(kosshiOPMLAttributeMapping),
blockTypeStringsAsMultipleRows,
forestFromKosshiParentIndex(rootID),
parentIndexFromDicts("parentID")(rootID)
)(dicts)
]
)(
doesFileExist(fp)
? jsonParseLR(
Object.assign(
Application.currentApplication(),
{ includeStandardAdditions: true }
)
.doShellScript(cmd)
)
: Left(`Database not found at path:\n\t${fp}`)
)
);
};
// ------------ TREE FROM KOSSHI RECORDS -------------
const koshiRowSQL = `
SELECT
id,
parentID,
content,
prose,
completed,
bookmarked,
createdAt,
updatedAt,
sortKey
FROM outlineRow
ORDER BY sortKey ASC;`
// Some leaves (with code or quote block text),
// may become longer sibling runs
const blockTypeStringsAsMultipleRows = forest => {
// Significantly affects only leaves in the forest
// Each tree is simply wrapped in a list if it is NOT a leaf
// or is neither a code nor quote block.
// Otherwise, it becomes a **series** of leaf trees, each with only a part of the text
// QUESTION, do we have to inflect the sort Key to preserve order ?
// IF SO, HOW ? Sequentially rising alphabetic affix ?
// go :: Tree Dict -> [Tree Dict]
const go = tree => {
const
dict = root(tree),
children = nest(tree),
text = dict.text;
const blockRows = type =>
rowTexts => 0 < rowTexts.length
? init(rowTexts).map(
text => Node({ ...dict, text, type })([])
)
.concat(
Node({ ...dict, text: last(rowTexts), type })(
children.flatMap(go)
)
)
: [];
return text.startsWith("```\n")
? text.endsWith("\n```")
// Rows of type CODE
// any descendants attached to last sub-row
? blockRows("code")(
text.slice(4, -4).split("\n")
)
: [Node(dict)(children.flatMap(go))]
: text.startsWith("> ")
// Rows of type QUOTE
// any descendants attached to last sub-row
? blockRows("quote")(
text.slice(2).split("\n> ")
)
: [Node(dict)(children.flatMap(go))];
};
return forest.flatMap(go);
};
// kosshiOPMLAttributeMapping :: Dict -> String
const kosshiOPMLAttributeMapping = dict =>
["text", "done", "type", "created", "modified"]
.flatMap(k => {
const v = dict[k];
return v
? "boolean" === typeof v
? "done" === k
? [[k, (new Date()).toISOString()]]
: [["type", v]]
: [[k, "text" === k ? htmlEncoded(v) : v]]
: [];
})
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
// parentIndexFromDicts :: String -> String -> [{id, parentID ....}]
const parentIndexFromDicts = parentKey =>
rootID => dicts => dicts.reduce(
(a, rowDict) => {
const parentID = rowDict[parentKey] || rootID;
return {
...a,
[parentID]: (a[parentID] || []).concat(rowDict)
};
},
{}
);
// forestFromKosshiParentIndex :: String ->
// Dict -> [Tree Dict]
const forestFromKosshiParentIndex = rootID =>
indexDict => {
const
taskPrefix = /^\[[ x]*\]\s+/u,
orderedPrefix = /^\d+\.\s+/u,
horizRule = /^(-{3})|(\*{3})$/u,
contentType = s => {
const
i = [taskPrefix, orderedPrefix, horizRule]
.findIndex(rgx => rgx.test(s));
return -1 === i
? ""
: ["task", "ordered", "hr"][i];
};
const go = rowDict => {
const
{ content, prose, completed, bookmarked, createdAt, updatedAt } = rowDict,
matchedType = contentType(content),
done = (1 === completed);
return Node({
text: "task" === matchedType
? content.replace(taskPrefix, "")
: content,
type: 0 < matchedType.length
? matchedType
: 1 === prose
? 1 === bookmarked
? "heading"
: "body"
: "unordered",
done,
created: (new Date(createdAt)).toISOString(),
modified: (new Date(updatedAt)).toISOString()
})(
(indexDict[rowDict.id] || []).map(go)
);
};
return indexDict[rootID].map(go);
};
// -------------------- BIKE OPML --------------------
// bikeOPMLFromForest :: (a -> String) -> (a -> String) ->
// Forest a -> XML String
const bikeOPMLFromForest = fAttribs =>
// A com.hogbaysoftware.bike.xml encoding of a
// Forest of `a` values, given two (a -> String):
// 1. A function returning a [k=v] attribute(s)
// string for an <li> tag
// 2. A function returning contents for a <p> tag.
trees => {
const d = " ";
const go = indent => x => {
const
dent = d + indent,
attribs = fAttribs(
root(x)
),
xs = nest(x);
return 0 < xs.length
? [
`${dent}<outline ${attribs}>`,
...xs.flatMap(go(d + dent)),
`${dent}</outline>`
]
: [`${dent}<outline ${attribs}/>`];
};
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<opml version="2.0">',
" <head>",
' <meta charset="utf-8" />',
" </head>",
" <body>",
trees.flatMap(
go(d.repeat(3))
)
.join("\n"),
" </body>",
"</opml>"
]
.join("\n");
};
// ----------------------- 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
);
};
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager
.defaultManager
.fileExistsAtPathIsDirectory(
$(fp).stringByStandardizingPath,
ref
) && !ref[0];
};
// filePath :: String -> FilePath
const filePath = s =>
// The given file path with any tilde expanded
// to the full user directory path.
ObjC.unwrap(
$(s).stringByStandardizingPath
);
// setClipOfTextType :: String -> String -> IO String
const setClipOfTextType = utiOrBundleID =>
txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(txt),
utiOrBundleID
),
txt
);
};
// writeFile :: FilePath -> String -> IO ()
const writeFile = fp =>
s => $.NSString.alloc.initWithUTF8String(s)
.writeToFileAtomicallyEncodingError(
$(fp).stringByStandardizingPath,
false,
$.NSUTF8StringEncoding, null
);
// --------------------- GENERIC ---------------------
// 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 || []
});
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: "Either",
Right: x
});
// apList (<*>) :: [(a -> b)] -> [a] -> [b]
const apList = fs =>
// The sequential application of each of a list
// of functions to each of a list of values.
// apList([x => 2 * x, x => 20 + x])([1, 2, 3])
// -> [2, 4, 6, 21, 22, 23]
xs => fs.flatMap(f => xs.map(f));
// bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
const bimap = f =>
// Tuple instance of bimap.
// A tuple of the application of f and g to the
// first and second values respectively.
g => ([a, b]) => [f(a), g(b)];
// 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);
// 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));
// htmlEncoded :: String -> String
const htmlEncoded = s => {
// A string in which &<>"' are replaced by
// the corresponding entities.
const dict = {
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'"
};
return s.replace(/[&<>"']/ug, c => dict[c] || c);
};
// init :: [a] -> [a]
const init = xs =>
// All elements of a list except the last.
0 < xs.length
? xs.slice(0, -1)
: null;
// length :: [a] -> Int
const length = xs =>
xs.length
// last :: [a] -> a
const last = xs => {
// The last item of a list.
const n = xs.length;
return 0 < n
? xs[n - 1]
: null;
};
// 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")
);
}
};
// nest :: Tree a -> [a]
const nest = tree =>
// The children of a given tree node.
tree.nest
// root :: Tree a -> a
const root = tree =>
// The value attached to a tree node.
tree.root;
// MAIN ---
return main();
})();