Still interested in Markdown support in OmniOutliner


#1

Ken: I’m very disappointed about the Markdown not making it to OmniOutliner. That would be super useful.

I realize there are a lot of flavors of markdown out there with several different features. But, at the least, using generic markdown as specified by John Gruber in 2003 would be a great starting point. That in itself would help tremendously.


How to Create Repeating Task Monthly on Specific Day [added in OmniFocus 3]
#2

The flavor of Markdown wasn’t the issue; it was more a question of how Markdown would actually interact with the app. Is Markdown just an import/export format, or an editing format? Should OmniOutliner show the Markdown content, or the rendered content? Or Markdown while editing a cell, but rendered when you stop editing? Should the outline structure follow the Markdown (e.g., when you type “###”)? Do you still see the “###” or does it just turn into indentation? What if the indentation is impossible to map to OmniOutliner’s levels (e.g. when you skip straight from “#” to “###” with no “##” in between)?

And so on, and so on. It hasn’t been too hard to get answers from a single person that are consistent and make sense to them, but it has been difficult to find common ground that meets a wide range of people’s expectations.


#3

Ken: Thanks for the explanation. That all makes sense. I hope some decent resolution can be found in the future. I think most of us who use markdown don’t need rendering. That’s nice and cute, but not necessary. And certainly not needed for a first implementation. But I can now better appreciate there’s a long list of issues to consider.


#4

I’m probably just proving your point, but to me only the first item is important:

Is Markdown just an import/export format, or an editing format?

The rest are details I could live with however they end up.

Thanks for the insight into the decision. I can see the challenge of trying to solve the problem while pleasing the most people.


#5

I think the real use case for me with OO and markdown is to get my outlines into Github. Personally, if I’m going to write in markdown, I’d use either a text editor or, for bigger markdown writing projects, something like Ulysses. I love the cleanliness of UI with OmniOutliner as is, but have always had to walk away from using it as a regular tool in my workflow due to limited interoperability with my other tools.

Happily (but unfortunately in this case) nobody on my dev team are huge Word users - we’re all text editor die hards in some form or another - rendering Word export a feature that isn’t very useful for us. What we really need is a nice clean way to export to our Style Guides (which sit on Github) - including wrapping images into an images folder and building markdown-friendly references to them. Honestly that would crush when it comes to dealing with the time it takes banging out that very specific type of work.

I dunno, just thought I’d share my biggest use-case for markdown exporting.


#6

Here is my preferences:

Is Markdown just an import/export format, or an editing format?

Why not both? They’re not mutually exclusive. But I’d definitely see most utility in MD as editing formats in the text fields in the app itself.

Should OmniOutliner show the Markdown content, or the rendered content? Or Markdown while editing a cell, but rendered when you stop editing?

IMO the best way to go is a hybrid of doing both. The app Day One does this fantastically.

Should the outline structure follow the Markdown (e.g., when you type “###”)? Do you still see the “###” or does it just turn into indentation? What if the indentation is impossible to map to OmniOutliner’s levels (e.g. when you skip straight from “#” to “###” with no “##” in between)?

I was thinking that MD was just formatting language for text fields, not to structure the data of OmniOutliner.


#7

Note: iThoughts added Markdown support - editing and display - in both its Mac and iOS flavours.


#8

Huh. Nice response, Ken. I hadn’t considered all of the possible ways that Markdown Support could be interpreted.

As I end up with more and more places that accept Markdown, my use case would be simply exporting. I’d love to continue to use OmniOutliner for my editing and organizing, and then export ordered and unordered lists.

I’ve given up trying to export to Muggle-native formats and just give them a PDF of my outlines :)


#9
  • Should OmniOutliner support markdown? Yes, because there are so many situations, where markdown is a standard. Second rtf is not flexible and too strong connected to a layout.
  • Should OmniOutliner use markdown as a native format, speaking open and save it directly? No, because there are features in OmniOutliner, that could be transferred from and to markdown in different ways. That would be confusing and would lead to misuse the markdown format.
  • Should OmniOutline import and export markdown? For sure, because it is a future format and it should be possible.
  • Should OmniOutliner show the hash sings of the headlines? No, because the headline is part of a structure and could be used as a parent node.
  • A question, I have no answer to: How can we differ between headline nodes and list nodes? Maybe with a depth of headline option while importing and exporting. I know, that with that not every situation is covered.

By the way, the above was written in OmniOutliner 5. Just copied the nodes and pasted them here. Wonderful easy.


#10

I’d also love markdown support.
There are lots of ways to implement it, some are probably easier than others. For me, the great thing about markdown is its portability. I can start a a document in one program, then move back and forth between multiple programs working on that same document without having to convert/worry about compatibility issues.
I would love to have markdown implemented like OPML but with styling (or whatever styling markdown supports). Where I can edit and save markdown files completely in app.
Exporting/importing Markdown would also be welcome, though less idea.
As for editing in markdown, couldn’t that be a checkbox option. Check the box to allow markdown syntax. I think the syntax only needs to be visible on the element that’s currently being edited, but I don’t think any one implementation would be a deal breaker.
I think there are many ways to implement, and sure, you may not make everyone happy immediately, but I think introducing some markdown compatibility will be a step in the right direction, and you can always iterate/add features if users convince you of the need.
Thanks for this great app.


#11

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:


#12

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


#13

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 = "outline.md";
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(theRow.style.namedStyles).length > 0) {
		switch(theRow.style.namedStyles[0].name()) {
			case "Heading 1":
				outlineText += "# ";
				break;
			case "Heading 2":
				outlineText += "## ";
				break;
			case "Heading 3":
				outlineText += "### ";
				break;
			case "Blockquote":
				outlineText += "> ";
				break;
			case "Ordered List":
				outlineText += "1. ";
				break;
			case "Unordered List":
				outlineText += "* ";
				break;
		}	
	}
	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
        app.closeAccess(openedFile)
 
        // Return a boolean indicating that writing was successful
        return true
    }
    catch(error) {
 
        try {
            // Close the file
            app.closeAccess(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 => {
        const
            as = row.topic.attributeRuns;
        return enumFromTo(0, as.length - 1)
            .reduce((s, i) => {
                const
                    attrib = as.at(i),
                    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) =>
	Array.from({
	length: Math.floor(n - m) + 1
    }, (_, i) => m + i);

#14

@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.


#15

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.


#16

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


#17

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


#18

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 https://babeljs.io

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.
    //
    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
    // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    // 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([].slice.call(args, 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(args.map(show).join(' -> '));
    };

    // 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 ? nest.map(jsoNodeMD(intLevel + 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: '[' + doc.name() + '](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: r.style.attributes.byName('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 ? ds.at(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);

sa.setTheClipboardTo(strClip);

strClip;

#19

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 -> pandoc.org

  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.

#20

Thanks so much deano1406! This works beautifully.