TaskPaper to Markdown Conversion

Hi all,

Just a quick note to let you know that I’ve dropped a short Perl script into a Github Gist to convert TaskPaper to Markdown format.

While it is quite simple, it does satisfy my primary requirement: it works for me…

1 Like

Thanks for sharing, if you want more people to find you should add a link to the TaskPaper Extensions Wiki:

I have added an entry to the Wiki, thanks…

1 Like

This doesn’t work for me. Any chance you can help me get it working?

In the meanwhile, here is a very basic (few options) script which aims to copy the active TaskPaper 3 document to the clipboard as Markdown.

For the moment it assumes that you want your top-level headings to have a single MD # hash, and that all projects are hash headings.

(Could add an option to change that if anyone else does use this script)

(You can adjust whether or not to include the TaskPaper tags by editing the options dictionary at the top of the script)

const options = {
    includeTags: true,

    // If wholeDoc is *false*, only items  currently
    // displayed in the front document are copied as MD.
    wholeDoc: true
};

It’s a JavaScript for Automation script which you should be able to run either from Script Editor, with the language selector at top left set to JavaScript, or by assigning it to a keyboard shortcut with something like Keyboard Maestro or FastScripts. See Using Scripts in the TaskPaper User’s Guide.

(Be sure to copy every line of the code below. The last lines are:

    // MAIN ---
    return main();
})();
JavaScript source – click disclosure triangle
(() => {
    'use strict';

    ObjC.import('AppKit');

    // Copy TaskPaper 3 active document to clipboard
    // as basic Markdown.

    // Rob Trew 2020

    // Ver 0.04 wholeDoc=false -> folded lines hidden.
    // Ver 0.03
    // - Added a wholeDoc option. If this is set to false
    //   only focused projects will be copied as Markdown.
    // Ver 0.02
    // - Extended to cover top level lines which
    //   are not projects.
    // - Simplified TP Context code - projects list
    //   harvested with evaluateItemPath.

    const options = {
        includeTags: true,

        // If wholeDoc is false, only items  currently
        // displayed in the front document are copied as MD.
        wholeDoc: true
    };

    // ---------------------JXA CONTEXT---------------------
    const main = () => {
        const ds = Application('TaskPaper').documents;
        return either(
            alert('Problem')
        )(
            compose(
                alert('MD copied to clipboard'),
                copyText,
                mdFromTPForest
            )
        )(
            bindLR(
                0 < ds.length ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(
                d => d.evaluate({
                    script: tp3ProjectForest.toString(),
                    withOptions: options
                })
            )
        );
    };

    // ------------------TASKPAPER CONTEXT------------------

    // tp3ProjectForest :: TP Editor -> [Tree Dict]
    const tp3ProjectForest = (editor, options) => {
        const showAll = options.wholeDoc;
        const main = () => Right(
            // Leftmost lines, or projects at any level.
            // (in whole document, or in visible focus)
            (
                showAll ? (
                    editor.outline.evaluateItemPath(
                        '/* union //project'
                    )
                ) : visibleLeftmostAndProjects()
            )
            // with any subtree of each,
            // (excluding sub-projects)
            .map(topLevel => foldTree(
                item => nest =>
                Node(item)(
                    nest.filter(
                        x => {
                            const dct = x.root;
                            return (
                                showAll || dct.visible
                            ) && ('project' !== dct.kvs[
                                'data-type'
                            ])
                        }
                    )
                )
            )(fmapPureTPTree(x => ({
                text: x.bodyContentString,
                kvs: x.attributes,
                depth: x.depth,
                visible: editor.isDisplayed(x),
                projectLevel: 'project' !== topLevel
                    .getAttribute('data-type') ? (
                        0
                    ) : topLevel.depth
            }))(topLevel)))
        );

        //  visibleLeftmostAndProjects :: () -> [TP Item]
        const visibleLeftmostAndProjects = () => {
            const
                xs = editor.displayedItems,
                outermost = minimum(xs.map(x => x.depth));
            return xs.filter(
                x => outermost === x.depth || (
                    'project' === x.attributes['data-type']
                )
            );
        };

        // --------GENERIC FUNCTIONS FOR TP 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
        });

        // Node :: a -> [Tree a] -> Tree a
        const Node = v => xs => ({
            type: 'Node',
            root: v,
            nest: xs || []
        });

        // fmapPureTPTree :: (TPItem -> a) -> TPItem -> Tree TPItem
        const fmapPureTPTree = f => item => {
            const go = x => Node(f(x))(
                x.hasChildren ? (
                    x.children.map(go)
                ) : []
            );
            return go(item);
        };

        // foldTree :: (a -> [b] -> b) -> Tree a -> b
        const foldTree = f =>
            // The catamorphism on trees. A summary
            // value obtained by a depth-first fold.
            tree => {
                const go = x => f(x.root)(
                    x.nest.map(go)
                );
                return go(tree);
            };

        // minimum :: Ord a => [a] -> a
        const minimum = xs =>
            0 < xs.length ? (
                xs.slice(1)
                .reduce((a, x) => x < a ? x : a, xs[0])
            ) : undefined;

        return main();
    };

    // FUNCTIONS FOR JXA

    // --------------BASIC MARKDOWN FUNCTIONS---------------

    // mdFromTPForest :: [Tree Dict] -> String
    const mdFromTPForest = xs => {
        const blnTags = options.includeTags;
        return concat(xs.map(foldMapTree(x =>
            '    '.repeat(
                x.depth - (
                    x.projectLevel + (
                        isProject(x) ? 0 : 1
                    )
                )
            ) + mdPrefix(x) + x.text + (
                blnTags ? mdTags(x) : ''
            ) + '\n'
        )));
    };

    // isProject :: Dict -> Bool
    const isProject = x =>
        'project' === x.kvs['data-type'];

    // mdPrefix :: Dict -> String
    const mdPrefix = x => {
        const type = x.kvs['data-type'];
        return 'note' !== type ? (
            'task' !== type ? (
                'project' !== type ? (
                    ''
                ) : ('\n' + '#'.repeat(x.depth) + ' ')
            ) : '- '
        ) : '';
    };

    // mdTags :: Dict -> String
    const mdTags = x => {
        const
            dct = x.kvs,
            ks = Object.keys(dct).filter(
                k => k.startsWith(
                    'data'
                ) && k !== 'data-type'
            );
        return 0 < ks.length ? (
            ' @' + ks.map(
                k => 0 < dct[k].length ? (
                    `${k.slice(5)}(${dct[k]})`
                ) : k.slice(5)
            ).join(' @')
        ) : '';
    };

    // ------------------JXA FUNCTIONS------------------

    // 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
        );
    };

    // copyText :: String -> IO String
    const copyText = s => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

    // GENERIC FUNCTIONS ----------------------------
    // https://github.com/RobTrew/prelude-jxa

    // Just :: a -> Maybe a
    const Just = x => ({
        type: 'Maybe',
        Nothing: false,
        Just: x
    });

    // 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
    });

    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const mappend = xs =>
        // A list or string composed by
        // the concatenation of two others.
        ys => xs.concat(ys);

    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => undefined !== m.Left ? (
            m
        ) : mf(m.Right);

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        x => fs.reduceRight((a, f) => f(a), x);

    // concat :: [[a]] -> [a]
    // concat :: [String] -> String
    const concat = xs =>
        0 < xs.length ? (
            xs.every(x => 'string' === typeof x) ? (
                ''
            ) : []
        ).concat(...xs) : xs;

    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;

    // foldMapTree :: Monoid m => (a -> m) -> Tree a -> m
    const foldMapTree = f =>
        // Result of mapping each element of the tree to
        // a monoid, and combining with mappend.
        node => {
            const go = x =>
                0 < x.nest.length ? mappend(f(x.root))(
                    foldl1(mappend)(x.nest.map(go))
                ) : f(x.root);
            return go(node);
        };

    // foldl1 :: (a -> a -> a) -> [a] -> a
    const foldl1 = f =>
        // Left to right reduction of the non-empty list xs,
        // using the binary operator f, with the head of xs
        // as the initial acccumulator value.
        xs => 1 < xs.length ? xs.slice(1)
        .reduce(uncurry(f), xs[0]) : xs[0];

    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        (x, y) => f(x)(y);

    // MAIN ---
    return main();
})();
3 Likes

Amazing! Thank you so much!

Not that I haven’t already taken up enough of your time, but is there a way to get this to run on the focused part of my document? Or on selected areas? Or copied TP3 text? I take notes in one huge document, and I only extract pieces from the document as needed.

Again, thank you so much. Even if you can’t help with my second request, I greatly appreciate that you took the time to make this script!

I’ll think about it – not before the weekend though now.

(Just made a slight update in the code above to allow for top-level – unindented – lines which are not projects)

You could experiment with Ver 0.03 (updated above) in which editing the value of the wholeDoc key in the options dictionary at the top of the script to false, should, I think, restrict the MD rendering to the visible focus of the front document.

const options = {
    includeTags: true,

    // If wholeDoc is false, only items  currently
    // visible in the front document are copied as MD.
    wholeDoc: true
};
2 Likes

I could try, but could you be more specific?

I just used it and it converted one of my TaskPaper documents just fine.

This is exactly what I needed! You made my day. Thanks again so much for your help!

1 Like

It could have been a user error…I’m not the most technical user. I can try again later to let you know what happens when I try to run it.

FWIW Ver 4 above extends the wholeDoc=false option to cover concealment by folding as well as focusing.

PS a note on mapping TaskPaper tab-indented outlines to the murkier and messier model used by Markdown and HTML:

There is more than one way to map TaskPaper to Markdown.

Consider the following TaskPaper tab-indented outline:

Some front material before our headings begin.

Primary project:
    Alpha
    Beta
    Embedded project:
        Iota
        Kappa
        Lambda
    Gamma
    Delta

Secondary project:
    Epsilon
    Zeta
    Eta
    Theta

In TaskPaper, tab-indents make the lines of ancestry very clear and unambiguous.

Gamma and Delta above are clearly children of the Primary project, and not of the Embedded Project

But it’s not very easy to make this clear in Markdown:

We could keep the line sequence and write something like:

Some front material before our headings begin.

# Primary project
Alpha
Beta

## Embedded Project
Iota
Kappa
Lambda

Gamma
Delta

# Secondary project
Epsilon
Zeta
Eta
Theta

But how clear are we then about the ancestry (if any) of Gamma and Delta ?

  • Are they top level material, not covered by headings, like the very first line ?
  • Are they part of the Embedded Project, separated only by a gap ?
  • Are they possibly (looks a bit unlikely), a continuation of the Primary project ?

So, for better or for worse, what this script chooses to do is to make the project groupings as clear as possible, even if that means we have to move a couple of lines:

In this approach, we can see at once that Gamma and Delta are part of the primary project (but the line order won’t always be quite the same as in TaskPaper):

Some front material before our headings begin.

# Primary project
Alpha
Beta
Gamma
Delta

## Embedded Project
Iota
Kappa
Lambda


# Secondary project
Epsilon
Zeta
Eta
Theta

See also

1 Like

(Which takes the approach of preserving line order rather than project grouping)

(see the discussion above)

This is one badass solution. Thank you so much for posting it.
Works like a charm and does, what I need.
Makes the best Outliner (TaskPaper) transfer to the best editor (Ulysses) seamlessly.
:+1::+1::+1::+1::+1: :slightly_smiling_face:

1 Like