Still interested in Markdown support in OmniOutliner


Hi y’all. I’m working on implementing markdown export in javascript. I’ve made enough progress for what I have to be better than nothing, but there’s still a lot to do (and I’m not that good with javascript…). Help welcome! Here’s what I have so far:


At a minimum, I would love to be able to export an outline in Markdown.

The developer of iThoughts, an amazing mindmap program, has a solution. You can make many decisions to get the text looking just how you want.

Right now, I need to

  1. export from OO to OPML.
  2. open in ithoughts
  3. export to markdown


Here’s a script to convert the current OO document to markdown. (@steve, this will give you your minimum ask at least – pop this in your OO scripts folder and you’ll be able to export markdown from the script menubar with two clicks.)

Thanks to @SGIII for figuring out how to handle markdown conversion within rows, and to @draft8 for cleaning up some of the code. You guys are great. The script doesn’t support all markdown tags (e.g. code blocks, underlined text), but it does support headings/blockquotes/bold/italics/lists. Adding the rest should be fairly easy. NB not tested very thoroughly, though I ran a number of longer outlines through it and it worked as expected.

If you want conversion to tex/pdf, an extended version of the script that does that is here.

function run() {

// Setup

var app = Application.currentApplication();
app.includeStandardAdditions = true;
var OmniOutliner = Application('OmniOutliner');
var doc = OmniOutliner.documents[0];
var fileName = "";
var desktopString = app.pathTo("desktop").toString()

// The text of the document

var outlineText = "";

// Loop through rows and append their text to outlineText

doc.rows().forEach(function(theRow) {
	if (Object.keys( > 0) {
		switch([0].name()) {
			case "Heading 1":
				outlineText += "# ";
			case "Heading 2":
				outlineText += "## ";
			case "Heading 3":
				outlineText += "### ";
			case "Blockquote":
				outlineText += "> ";
			case "Ordered List":
				outlineText += "1. ";
			case "Unordered List":
				outlineText += "* ";
	outlineText += rowTextMD(theRow);
	outlineText += "\r";

// Convert the text of the paper to UTF8 encoding

outlineText = $.NSString.alloc.initWithUTF8String(outlineText);

// Write outlineText to a new markdown file

var file = `${desktopString}/${fileName}.md`
outlineText.writeToFileAtomicallyEncodingError(file, true, $.NSUTF8StringEncoding, null);

return true;


// From apple's documentation for Javascript for Automation
function writeTextToFile(text, file, overwriteExistingContent) {
    try {
        // Convert the file to a string
        var fileString = file.toString()
        // Open the file for writing
        var openedFile = app.openForAccess(Path(fileString), { writePermission: true })
        // Clear the file if content should be overwritten
        if (overwriteExistingContent) {
            app.setEof(openedFile, { to: 0 })
        // Write the new content to the file
        app.write(text, { to: openedFile, startingAt: app.getEof(openedFile) })
        // Close the file
        // Return a boolean indicating that writing was successful
        return true
    catch(error) {
        try {
            // Close the file
        catch(error) {
            // Report the error is closing failed
            console.log(`Couldn't close file: ${error}`)
        // Return a boolean indicating that writing was successful
        return false

// Code below written by draft8, based on code written by SGIII, in turn adapted from AppleScript code written by Rob Trew

const rowTextMD = row => {
            as = row.topic.attributeRuns;
        return enumFromTo(0, as.length - 1)
            .reduce((s, i) => {
                    attrib =,
                    fnt = attrib.font(),
                    bld = (fnt.includes('Bold') || fnt.includes('Black')) ? (
                    ) : '',
                    ital = fnt.includes('Italic') ? '*' : '';
                return s + bld + ital + attrib.text() + ital + bld;
            }, '') + '\n';
// enumFromTo :: Int -> Int -> [Int]
const enumFromTo = (m, n) =>
	length: Math.floor(n - m) + 1
    }, (_, i) => m + i);


@dmgrant, I’m grateful for the response! What kind of script is this? It doesn’t look like an applescript. I tried to save it using script editor, but it didn’t work. If you have a chance, could you tell me how to use it.


Hi Steve! It’s javascript – apple added javascript scripting (called “Javascript for Automation” or JXA) recently. It’s significantly more powerful than applescript (and less painful to use IMO). To use the script you’ll just need to set the script type to “javascript” instead of “applescript” in a pulldown menu at the top of the script document.


See a draft (Javascript for Automation) script using the same options as iThoughts at:


Thank you. I didn’t know about the javascript automation. Unfortunately, I’m getting a syntax error. Another reason to bake this into OmniOutliner!


I wonder if you are running a pre-Sierra version of macOS ?

That draft of the script is in ES6 JavaScript, and earlier version of macOS only support a mainly ES5 JS.

Generally you can get an ES5 version of an ES6 script by pasting it into the Babel JS REPL at

Here, for you to test, is an ES5 version of that script, as generated by Babel, and additionally wrapped in some lines that place a copy of the output in the clipboard:

var strClip = function () {
    'use strict';

    // OmniOutliner to TEXT-NEST, then TEXT-NEST to MARKDOWN

    // example of format translation through a textNest hasSubTopics

    // Rough draft ver 0.05  (ES5 translation, from Babel JS REPL)
    // 0.05 -- Moved extra linefeed from after hash header to before
    // 0.04 -- Simplified ooRowsJSO
    // 0.03 -- Slightly faster version of cellTextMD – fewer AE events

    // Copyright(c) 2017 Rob Trew
    // Permission is hereby granted, free of charge, to any person obtaining a
    // copy of this software and associated documentation files(the "Software"),
    // to deal in the Software without restriction, including without limitation the rights
    // to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
    // copies of the Software, and to permit persons to whom the Software is
    // furnished to do so, subject to the following conditions:
    // The above copyright notice and this permission notice shall be included in all
    // copies or substantial portions of the Software.
    // SOFTWARE.

    // GENERIC ----------------------------------------------------------------

    // any :: (a -> Bool) -> [a] -> Bool

    var any = function any(f, xs) {
        return xs.some(f);

    // concat :: [[a]] -> [a] | [String] -> String
    var concat = function concat(xs) {
        return xs.length > 0 ? function () {
            var unit = typeof xs[0] === 'string' ? '' : [];
            return unit.concat.apply(unit, xs);
        }() : [];

    // curry :: Function -> Function
    var curry = function curry(f) {
        for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
            args[_key - 1] = arguments[_key];

        var go = function go(xs) {
            return xs.length >= f.length ? f.apply(null, xs) : function () {
                return go(xs.concat(Array.from(arguments)));
        return go([], 1));

    // flip :: (a -> b -> c) -> b -> a -> c
    var flip = function flip(f) {
        return function (a, b) {
            return f.apply(null, [b, a]);

    // isInfixOf :: Eq a => [a] -> [a] -> Bool
    var isInfixOf = function isInfixOf(needle, haystack) {
        return haystack.includes(needle);

    // log :: a -> IO ()
    var log = function log() {
        for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
            args[_key2] = arguments[_key2];

        return console.log(' -> '));

    // min :: Ord a => a -> a -> a
    var min = function min(a, b) {
        return b < a ? b : a;

    // replicate :: Int -> a -> [a]
    var replicate = function replicate(n, a) {
        var v = [a],
            o = [];
        if (n < 1) return o;
        while (n > 1) {
            if (n & 1) o = o.concat(v);
            n >>= 1;
            v = v.concat(v);
        return o.concat(v);

    // replicateS :: Int -> String -> String
    var replicateS = function replicateS(n, s) {
        return concat(replicate(n, s));

    // show :: a -> String
    var show = function show(x) {
        return JSON.stringify(x, null, 2);

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    var zipWith = function zipWith(f, xs, ys) {
        return Array.from({
            length: min(xs.length, ys.length)
        }, function (_, i) {
            return f(xs[i], ys[i]);

    // JSO TEXT-NEST TO MARKDOWN ----------------------------------------------

    // jsoMarkdown :: Dictionary -> [Node] -> String
    var jsoMarkdown = function jsoMarkdown(dctOptions, xs) {
        var indent = dctOptions.indent || '\t',
            headerLevels = dctOptions.headerLevels || 3,
            listMarker = dctOptions.listMarker || '-',
            notesIndented = dctOptions.notesIndented || true;
        var jsoNodeMD = curry(function (intLevel, strIndent, node) {
            var blnHash = intLevel <= headerLevels,
                index = node.number,
                strNum = blnHash || index === undefined ? '' : index + '.',
                strPrefix = (blnHash ? replicateS(intLevel, '#') : strNum || listMarker) + ' ',
                noteIndent = notesIndented ? strIndent + indent : strIndent,
                nextIndent = blnHash ? '' : indent + strIndent,
                note = node.note,
                strNotes = note !== '' ? note.split('\n').map(function (x) {
                return noteIndent + x;
            }).join('\n') + '\n\n' : '',
                nest = node.nest;
            return (blnHash ? '\n' : '') + strIndent + strPrefix + node.text + '\n' + strNotes + (nest.length > 0 ? + 1, nextIndent)).join('') + '\n' : '');

        return xs.reduce(function (a, x) {
            return a + jsoNodeMD(1, '', x) + '\n';
        }, '');

    // OMNI-OUTLINER TO JSO / JSON --------------------------------------------

    // Either with or without (see blnMD) MarkDown emphases for Bold/Italic
    // ooRowsJSO :: Bool -> OO.Document -> [Node {text: String, nest: [Node]]}
    var ooDocJSO = function ooDocJSO(blnMD, doc) {
        return [{
            text: '[' + + '](file:://' + Path(doc.file()).toString() + ')',
            nest: ooRowsJSO(blnMD, doc.children)

    // ooRowsJSO :: Bool -> OO.Rows -> Node {text: String, nest: [Node]}
    var ooRowsJSO = function ooRowsJSO(blnMD, rows) {
        return rows().map(function (r, i) {
            return {
                text: blnMD ? cellTextMD(r.topicCell) : r.topic(),
                number:'heading-type(com.omnigroup.OmniOutliner)').value() !== 'None' ? i + 1 : undefined,
                note: blnMD ? cellTextMD(r.noteCell) : r.note(),
                nest: r.hasSubTopics ? ooRowsJSO(blnMD, r.children) : []

    // contains :: String -> String -> Bool
    var contains = curry(flip(isInfixOf));

    // cellTextMD :: OO.Cell -> String
    var cellTextMD = function cellTextMD(cell) {
        var as = cell.richText.attributeRuns;
        return zipWith(function (txt, fnt) {
            var bld = any(contains(fnt), ['Bold', 'Black']) ? '**' : '',
                ital = isInfixOf('Italic', fnt) ? '*' : '';
            return bld + ital + txt + ital + bld;
        }, as.text(), as.font()).join('');

    // TEST -------------------------------------------------------------------
    var ds = Application('OmniOutliner').documents,
        d = ds.length > 0 ? : undefined;

    // Edit optional values for indent, headerLevels, notesIndented, listMarker
    return d ? jsoMarkdown({
        indent: replicateS(4, ' '), // or '\t'
        headerLevels: 3,
        notesIndented: true,
        listMarker: '-' // or '*' or '+'
    }, ooDocJSO(true, d)[0].nest) : '';

var a = Application.currentApplication(),
    sa = (a.includeStandardAdditions = true, a);




If you use Hazel, I have a simple workaround for you.

You will need to install a utility called Pandoc, details of what Pandoc is, how it works and how to install can be found here ->

  1. Install Pandoc
  2. Create a folder for Hazel to watch
  3. Create the follow rule on the folder in Hazel

  1. Click edit script and add the script (text below)

pandoc "$1" -t markdown -o "$1"
  1. Save the file you want to convert to Markdown in the folder as an OPML file. Hazel will run the script and convert the file to markdown for you.


Thanks so much deano1406! This works beautifully.


Of course, I’d still like to see OO export markdown without such help.


I’m sure Markdown support will appear at some point, when I’m working on documents I use OmniOutliner. But when I’m exchanging files with colleagues or keeping them for long term Archive I use Markdown.

Import and Export into OmniOutliner would be top of my list, followed by Markdown support within OmniOutlier a secondary consideration.


I need this feature NOW.
I’ve been creating weekly reports in OmniOutliner… but I have no way to copy and paste the report into an email.,. at least none that i’ve found.

I mean, sure, I can get a simple “text” format, but then I have to go and painfully reformat the darn thing… I’m going to have to write a python script to do this form me!!

Is there any sort of “plugin” capability for OO that I could use to add a “Copy as Formatted…”?


@kutenai Could you explain what reformatting you need to do, please? My guess would be removing the handles and/or checkboxes. If you don’t want any, you can control this by clearing the fields in the Text or RTF Export tabs of the Preferences. You can even change indentation values if you want something different.


The output indentation and formatting does not translate well to my mail client – MailMate – which accepts a nice markdown format. I just want the Header rows to have a ‘#’ as prefix, and then indent the child rows.

You pointed out the configuration options, and that will help actually. I can make the ‘header rows’ have a # in front of them. Would be nice to have some additional controls, but this help.

As a side-note, I used bbedit and a custom text filter to re-format to clean Markdown.


What would be great would be controlling the handles on export - maybe simply having hashtags instead of handles or even determining the number of hashtags (ie markdown title hierarchy).
Only possible problem then would be differentiating between ‘text’ and ‘title’ maybe this is done somehow via checkboxes ?
Clumsy workaround would be putting all ‘text’ into notes…


I’ve been using a tool called Ulysses. It’s really nice, but not a replacement for Omnioutliner. Still, it has some killer features.

  • It syncs seamlessly with iCloud, including other computer and mobile… super nice!
  • You can highlight a section of a document, and “copy as…” HTML, Markdown, Plain Text, or Rich Text.

That last feature is worth it’s weight in gold. I’d pay for an upgrade to OO right now if it has such a feature. Without the slightest hint of hesitation… OO6…


If any of you folks have bbedit, I have a text filter I use to convert the ‘cut-paste’ text from OO to markdown. It’s quite simple. You will need to adjust that first #! line.
The script takes tab indented stuff, and converts it to indented and *'ed markdown. It works, and could certainly be updated…

#!/usr/bin/env -S ${HOME}/bin/.venv3/bin/python

import fileinput
import re
import json
if __name__ == "__main__":

    for a_line in fileinput.input():
        line = a_line.rstrip('\s+\n')
        m = re.match(r"^(\t*)[ ]*-\s+(.*)$", line)
        if m:
            tabs =
            txt =
            tabcount = len(tabs)
            if tabcount == 0:
                print("# {}\n".format(txt))
                print("{}* {}\n".format("    "*(tabcount-1), txt))


I keep hoping OO will pick-up importing MD outlines. This past week I had a large outline in MD using * and tabs for nesting. I used to have a workflow that worked passably, but I could not figure it out. I thought it was bringing MD into iThoughts or MindNode and exporting as OPML and pulling that into OO.

I know I have turned the * and tab into headers and had that work through a couple different workflows, but it doesn’t copy into Pages, Google Docs, nor Word well when I go that route.

I’m often starting an outline from a rough note (nearly all my notes start as markdown). Sometimes after a short while I will outline my notes in a mind map app, or OmniOutliner than round trip them using OPML from the other.

I like all the exporting MD from OO that I’m seeing shared out, now wondering about the other direction.


I’ll be honest, the developer replies in this forum have been really discouraging. There seem to be a lot of customers basically saying, “this is an important feature and would make my experience of this product better”. As far as I can see, the response has been to basically skirt those concerns and blame the fact that no single set of preferences exist for how they would be implemented.

I basically love the look and feel of your product. A direct export to markdown would make it much more useful for me, because I want to be able to get my rough outline (written in OO) into Ulysses and then fill it out there, with minimal rigmarole in export. The richtext and Docx exports are actually extremely useless to me, and the plaintext with tab indentation even more so. It seems like including a markdown export feature would be pretty simple, in the grand scheme of things—why not just believe your customers when they say this would be a desirable feature? The fact that people are resorting to pairing Hazel with Pandoc should tell you something about how much a simple export feature would help.