Copy As Tab Indented Outline

Based on @complexpoint excellent work, I created this macro.

@jessegrosjean would be possible to obtain (via scripting) the contents of the full outline and the path to the current file ?

In the meantime, this is a solution, I think.

Code:

(() => {
    "use strict";

    // ---------- DEMO OF COPY BIKE AS TAB OUTLINE ----------

    ObjC.import("AppKit");

    // unlocked2412 based on earlier work by Rob Trew @2022
    // Draft ver 0.001


    //  DEMO: copy Bike selection as tab outline

    // main :: IO ()
    const main = () => {

        const
            se = Application("System Events");

        return (
            $.NSPasteboard.generalPasteboard.clearContents,
            either(
                alert("No clipboard for Bike")
            )(
                txt => Object.assign(
                    Application.currentApplication(), {
                        includeStandardAdditions: true
                    }
                ).displayNotification(txt, {
                    withTitle: "Copied as Indented Text",
                    subtitle: "Bike outline:"
                })
            )(
                bindLR(
                    bindLR(
                        bikeFrontDocFilePathLR()
                    )(
                        compose(readFileLR, fst)
                    )
                )(
                    xml => bindLR(
                        tabOutlineFromBikeStringLR(
                            xml
                        )
                    )(
                        compose(Right, copyText)
                    )
                )
            )
        );
    };

    // --------------- TAB OUTLINE FROM BIKE ----------------

    // tabOutlineFromFromBikeStringLR ::
    // HTML String -> Either String String
    const tabOutlineFromBikeStringLR =
        bikeHTML => {
            return bindLR(treeFromBikeStringLR(bikeHTML))(
                tree => Right(
                    // Each node decorated with an
                    // outline level property.
                    tabOutlineFromForest(
                        map(
                            fmapTree(node => node.text)
                        )(
                            (
                                tree.nest
                            )
                        )
                    )
                )
            );
        }

    // ---------------------- BIKE -----------------------

    // bikeFrontDocFilePathLR  :: () -> 
    // Either String (FilePath, String)
    const bikeFrontDocFilePathLR = () => {
        // ObjC.import ('AppKit')
        const
            appProcess = Application('System Events')
            .applicationProcesses.byName("Bike"),
            ws = appProcess.windows;

        return bindLR(
            0 < ws.length ? Right(
                ws.at(0).attributes.byName('AXDocument').value()
            ) : Left(`No document windows open in Bike.`)
        )(
            docURL => null !== docURL ? (
                Right([
                    decodeURIComponent(docURL.slice(7)),
                    appProcess.bundleIdentifier()
                ])
            ) : Left(`No saved document active in ${appName}.`)
        );
    };

    // treeFromBikeStringLR :: Bike String ->
    // Either String [Tree String]
    const treeFromBikeStringLR = s => {
        const
            error = $(),
            node = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                s, 0, error
            );

        return node.isNil() ? (() => {
            const
                problem = ObjC.unwrap(
                    error.localizedDescription
                );

            return Left(
                `Not parseable as Bike:\n\n${problem}`
            );
        })() : treeFromBikeXMLNodeLR(node);
    };


    // treeFromBikeXMLNodeLR :: XML Node ->
    // Either String Tree String
    const treeFromBikeXMLNodeLR = xmlRootNode => {
        const
            unWrap = ObjC.unwrap,
            topNode = xmlRootNode.childAtIndex(0);

        return bindLR(
            "html" === unWrap(topNode.name) ? (
                Right(topNode.childAtIndex(1))
            ) : Left("Expected top-level <html> node.")
        )(body => bindLR(
            "body" === unWrap(body.name) ? (
                Right(body.childAtIndex(0))
            ) : Left("Expected a <body> node in bike HTML.")
        )(topUL => "ul" === unWrap(topUL.name) ? (
            Right(
                Node({
                    id: attribVal(topUL)("id"),
                    text: "[Virtual Root]"
                })(unWrap(topUL.children).map(
                    lineTree
                ))
            )
        ) : Left("Expected a top <ul> node in bike HTML.")));
    };


    // lineTree :: XMLNode LI -> Tree String
    const lineTree = node => {
        const
            unWrap = ObjC.unwrap,
            pNode = node.childAtIndex(0);

        return Node(
            unWrap(node.attributes).reduce(
                (a, attrib) => Object.assign(a, {
                    [unWrap(attrib.name)]: unWrap(
                        attrib.stringValue
                    )
                }), {
                    text: unWrap(pNode.stringValue)
                }
            )
        )(
            1 < parseInt(node.childCount, 10) ? (
                unWrap(pNode.nextSibling.children)
                .map(lineTree)
            ) : []
        );
    };


    // attribVal :: XMLNode -> String -> String
    const attribVal = xmlNode =>
        // The value of a named attribute of the XML node.
        k => ObjC.unwrap(
            xmlNode.attributeForName(k).stringValue
        ) || "";

    // ---------------- JS TREES ------------------------
    // tabOutlineFromForest :: [Tree String] -> String
    const tabOutlineFromForest = trees =>
        trees.flatMap(
            foldTree(x => xs => [
                x,
                ...xs.flat().map(s => `\t${s}`)
            ])
        ).join("\n");


    // ---------------- LIBRARY FUNCTIONS ----------------

    // ----------------------- JXA -----------------------
    // https://github.com/RobTrew/prelude-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
            );
        };


    // appIsInstalled :: String -> Bool
    const appIsInstalled = bundleID =>
        Boolean(
            $.NSWorkspace.sharedWorkspace
            .URLForApplicationWithBundleIdentifier(
                bundleID
            )
            .fileSystemRepresentation
        );


    // clipOfTypeLR :: String -> Either String String
    const clipOfTypeLR = utiOrBundleID => {
        const
            clip = ObjC.deepUnwrap(
                $.NSString.alloc.initWithDataEncoding(
                    $.NSPasteboard.generalPasteboard
                    .dataForType(utiOrBundleID),
                    $.NSUTF8StringEncoding
                )
            );

        return 0 < clip.length ? (
            Right(clip)
        ) : Left(
            "No clipboard content found " + (
                `for type '${utiOrBundleID}'`
            )
        );
    };


    // copyText :: String -> IO String
    const copyText = s => {
        const pb = $.NSPasteboard.generalPasteboard;

        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

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

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


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


    // 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 => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // enumFromTo :: Int -> Int -> [Int]
    const enumFromTo = m =>
        n => Array.from({
            length: 1 + n - m
        }, (_, i) => m + i);

    // fmapTree :: (a -> b) -> Tree a -> Tree b
    const fmapTree = f => {
        // A new tree. The result of a
        // structure-preserving application of f
        // to each root in the existing tree.
        const go = t => Node(
            f(t.root)
        )(
            t.nest.map(go)
        );

        return go;
    };

    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];


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

        return go;
    };


    // forestOutline :: String -> (a -> String) ->
    // Forest a -> String
    const forestOutline = indentUnit =>
        // An indented outline of the nodes
        // (each stringified by f) of a forest.
        f => forest => forest.flatMap(
            foldTree(
                x => xs => 0 < xs.length ? [
                    f(x), ...xs.flat(1)
                    .map(s => `${indentUnit}${s}`)
                ] : [f(x)]
            )
        ).join("\n");


    // 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.
        xs => 0 < xs.length ? (() => {
            const [h, ...t] = xs;
            const [groups, g] = t.reduce(
                ([gs, a], x) => eqOp(x)(a[0]) ? (
                    Tuple(gs)([...a, x])
                ) : Tuple([...gs, a])([x]),
                Tuple([])([h])
            );

            return [...groups, g];
        })() : [];

    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => [...xs].map(f);


    // mapAccumL :: (acc -> x -> (acc, y)) -> acc ->
    // [x] -> (acc, [y])
    const mapAccumL = f =>
        // A tuple of an accumulation and a list
        // obtained by a combined map and fold,
        // with accumulation from left to right.
        acc => xs => [...xs].reduce(
            ([a, bs], x) => second(
                v => bs.concat(v)
            )(
                f(a)(x)
            ),
            Tuple(acc)([])
        );


    // nest :: Tree a -> [a]
    const nest = tree =>
        tree.nest;


    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = f =>
        // e.g. groupBy(on(eq)(length))
        g => a => b => f(g(a))(g(b));

    // readFileLR :: FilePath -> Either String IO String
    const readFileLR = fp => {
        // Either a message or the contents of any
        // text file at the given filepath.
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );

        return ns.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.unwrap(ns));
    };


    // snd :: (a, b) -> b
    const snd = tpl =>
        // Second member of a pair.
        tpl[1];


    // treeWithLevels :: Int -> Tree Dict -> Tree Dict
    const treeWithLevels = topLevel =>
        // A tree in which each root dictionary is
        // decorated with an integer 'level' value,
        // where the level of the top node is given,
        // the level of its children is topLevel + 1,
        // and so forth downwards.
        tree => {
            const go = level =>
                node => {
                    const
                        nodeRoot = node.root,
                        fullLevel = parseInt(
                            nodeRoot.indent, 10
                        ) || level;

                    return Node(
                        Object.assign({}, nodeRoot, {
                            level: fullLevel
                        })
                    )(
                        node.nest.map(go(1 + fullLevel))
                    );
                };

            return go(topLevel)(tree);
        };


    // root :: Tree a -> a
    const root = tree =>
        // The value attached to a tree node.
        tree.root;


    // second :: (a -> b) -> ((c, a) -> (c, b))
    const second = f =>
        // A function over a simple value lifted
        // to a function over a tuple.
        // f (a, b) -> (a, f(b))
        ([x, y]) => Tuple(x)(f(y));


    // MAIN
    return main();
})();

KM Macro:

Copy as Tab Outline for Bike.app (preview 26+).kmmacros.zip (5.6 KB)

1 Like

Thanks for sharing and please keep a mental or actual list of all the things I need to add to make scripting better. I’m resisting thinking about the scripting implementation at the moment… I really need to get Bike 1.0 out the door first. But good scripting support will be a focus post 1.0.

@jessegrosjean would be possible to obtain (via scripting) the contents of the full outline and the path to the current file ?

In the next beta release I’ll turn on the standard AppleScript suite. Nothing Bike specific yet, but this will at least allow you to access current file path and things like that.


I’m not sure, but I think there may be an easier way to implement this scripts functionality. When Bike does Edit > Copy it writes the current selection to the pasteboard in a number of formats .bike, .txt, .opml.

User will need to select region they want, but then I think Copy and then read plain text from pasteboard is another way to get indented text outline.

Of course. I made extensive use of TP scripting capabilities, so I will keep a similar list for Bike.

Yes, I realize that now. Thank you for pointing that out.

I think this is possible in latest release through standard AppleScript suite.

2 Likes

I think this is possible in latest release through standard AppleScript suite.

A rough sketch:

Expand disclosure triangle to view JS Source
(() => {
    "use strict";

    // Parsing XML of Bike front document
    // to a (JSON representation of a)
    // Tree of {id::String, text::String} nodes,
    // with a virtual root representing the document itself
    // and carrying the document ID.

    // Rob Trew @2022
    // Sketch 0.01

    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            documents = bike.documents;

        return either(
            message => message
        )(
            tree => JSON.stringify(tree, null, 2)
        )(
            bindLR(
                0 < documents.length ? (
                    Right(documents.at(0))
                ) : Left("No open documents found in Bike.")
            )(doc => bindLR(
                readFileLR(`${doc.file()}`)
            )(xml => bindLR(
                treeFromBikeStringLR(xml)
            )(tree => {
                console.log(
                    `Root id :: ${tree.root.id}`
                );
                console.log(
                    `Top level item count:: ${tree.nest.length}`
                );

                return Right(tree);
            })))
        );
    };

    // ---------------------- BIKE -----------------------

    // treeFromBikeStringLR :: Bike String ->
    // Either String [Tree {id::String, text::String]
    const treeFromBikeStringLR = s => {
        const
            error = $(),
            node = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                s, 0, error
            );

        return node.isNil() ? (() => {
            const problem = ObjC.unwrap(
                error.localizedDescription
            );

            return Left(
                `Not parseable as Bike:\n\n${problem}`
            );
        })() : treeFromBikeXMLNodeLR(node);
    };

    // treeFromBikeXMLNodeLR :: XML Node ->
    // Either String Tree {id:String text:String}
    const treeFromBikeXMLNodeLR = xmlRootNode => {
        // lineTree :: XMLNode LI -> Tree String
        const lineTree = node => {
            const pNode = node.childAtIndex(0);

            return Node({
                id: lineID(node),
                text: unWrap(pNode.stringValue) || ""
            })(
                1 < parseInt(node.childCount, 10) ? (
                    unWrap(pNode.nextSibling.children)
                    .map(lineTree)
                ) : []
            );
        };

        const
            unWrap = ObjC.unwrap,
            lineID = xmlNode => unWrap(
                xmlNode.attributeForName("id").stringValue
            ),
            topNode = xmlRootNode.childAtIndex(0);

        return bindLR(
            "html" === unWrap(topNode.name) ? (
                Right(topNode.childAtIndex(1))
            ) : Left("Expected top-level <html> node.")
        )(body => bindLR(
            "body" === unWrap(body.name) ? (
                Right(body.childAtIndex(0))
            ) : Left("Expected a <body> node in bike HTML.")
        )(topUL => "ul" === unWrap(topUL.name) ? (
            Right(
                Node({
                    id: lineID(topUL),
                    txt: "[Virtual Root]"
                })(unWrap(topUL.children).map(
                    lineTree
                ))
            )
        ) : Left("Expected a top <ul> node in bike HTML.")));
    };

    // ----------------------- JXA -----------------------

    // readFileLR :: FilePath -> Either String IO String
    const readFileLR = fp => {
        // Either a message or the contents of any
        // text file at the given filepath.
        const
            e = $(),
            ns = $.NSString
            .stringWithContentsOfFileEncodingError(
                $(fp).stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                e
            );

        return ns.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.unwrap(ns));
    };


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


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.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 => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);

    return main();
})();
2 Likes

Thank you ! That’s good. Is it possible to obtain the contents of the full outline without reading the file ?

You are thinking of a parsed model of the outline nesting ?

Thank you for asking clarification. In terms of TaskPaper, I would this equivalent methods:

root (Outline class)
items (Outline class)
displayedItems (OutlineEditor class)
firstDisplayedItem (OutlineEditor class)

This is very clever.

But it always copies the entire outline. I have a Keyboard Maestro macro that copies just the selected nodes in Bike as tab-indented text. The macro has two steps:

  1. Copy.
    Literally just uses KM’s Copy command which just a normal copy. Bike currently uses 4 leading spaces per level of indentation.

  2. Execute Shell Script
    Input: System Clipboard
    Save to: System Clipboard

The shell script in step 2:

#!/usr/bin/env perl

# Leading spaces to tabs.

use strict;
use warnings;

my $tabwidth = 4;

while(<>) {
	s{
		^(([ ]{$tabwidth})+)
	}{
		"\t" x (length($1) / $tabwidth)
	}ex;
	print;
}

The only downside to my macro is that if you use a clipboard history manager, you’ll wind up with two entries, first the original leading-space text from Bike, then the leading-tab version from the macro. I’m fine with that.

1 Like

And, of course, we can express that directly in JavaScript for Automation too:

Copy Bike selection as tab-indented text.kmmacros.zip (1.8 KB)

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    ObjC.import("AppKit");

    return (
            $.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString)
            .js || ""
        )
        .split("\n")
        .map(s => s.replace(/ {4}/gu, "\t"))
        .join("\n");
})();

Though as you say, I think my personal preference would, FWIW, be for a tab-indented (rather than space indented) text format by default.


and I guess there could be post-prefix sequences of ' {4}' too : -)

Expand disclosure triangle to view alternate JS Source
(() => {
    "use strict";

    ObjC.import("AppKit");

    return (
            $.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString)
            .js || ""
        )
        .split("\n")
        .map(s => {
            const i = [...s].findIndex(c => " " !== c);

            return i && (-1 !== i) ? (
                "\t".repeat(Math.floor(i / 4)) + (
                    s.slice(i)
                )
            ) : s;
        })
        .join("\n");
})();
3 Likes