Script to copy entire outline to clipboard

G’day,

  1. Why did I wait so long to update Bike…current version is fabulous…well done Jesse

  2. Wanting to adapt your copy selection as csv to copy entire outline as tabbed text.

I can get the outline with the below adaption of your script, just not sure how to substitute tabs. Suggestions welcome.
Thanks
Rob

var bikeApp = Application("Bike")

bikeApp.includeStandardAdditions = true

var document = bikeApp.documents.at(0)
var zip = (a, b) => a.map((k, i) => [k, b[i]])

var ancestors = []
var results = []
var allRows = document.rows()

for (each of allRows) {
	const eachLevel = each.level()
	
	while (ancestors.length > 0 && ancestors[ancestors.length - 1].level() >= eachLevel) {
		ancestors.pop()
	}
	
	ancestors.push(each)
	
	if (!each.containsRows()) {
		let line = []
		for (eachAncestor of ancestors) {
			let eachName = eachAncestor.name()
			let eachEscapedName = eachName.replace(/"/g, '""')
			line.push('"' + eachEscapedName + '"')
		}
		results.push(line.join())
	}
}

bikeApp.setTheClipboardTo(results.join('\n'))

results

Tab-indented text is one of the formats copied to the clipboard automatically on ⌘C

What application are you pasting into ?


Choice of the format used in pasting is made by the application you paste into. If you are pasting into something like Numbers, which stops looking for plain text when it finds an HTML or OPML pasteboard item, you can reduce the clipboard contents to plain text only before pasting.

If you are using Keyboard Maestro, you can bind a keystroke to something like this:

BIKE Outliner – copy as (tab-indented) plain text only.kmmacros.zip (3.1 KB)

Which uses the following JavaScript for Automation source:

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

    // BIKE :: Copy As Plain Text Only

    // To get around a bug in Numbers.app,
    // which seems to stop looking for plain text when
    // (inside the editing field of a spreadsheet cell)
    // it sees HTML and OPML outlines in the clipboard.

    // Ver 0.04
    // Rob Trew @2020

    ObjC.import("AppKit");

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

        return either(
            alert("Copy as Text Only")
        )(
            clip => clip
        )(
            doc.exists()
                ? (() => {
                    const
                        selectedText = doc.selectedText()
                            .trimStart();

                    return Boolean(selectedText)
                        ? (
                            copyText(selectedText),
                            // delay(0.2),
                            bindLR(
                                clipTextLR()
                            )(
                                clip => clip === selectedText ? (
                                    Right(clip)
                                ) : Left(
                                    [
                                        "Text not copied ...",
                                        "Try increasing delay."
                                    ]
                                        .join("\n")
                                )
                            )
                        )
                        : Left("Nothing selected in Bike.");
                })()
                : Left("No document open in Bike.")
        );
    };

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


    // clipTextLR :: () -> Either String String
    const clipTextLR = () => {
        // Either a message, (if no clip text is found),
        // or the string contents of the clipboard.
        const
            v = ObjC.unwrap(
                $.NSPasteboard.generalPasteboard
                    .stringForType($.NSPasteboardTypeString)
            );

        return Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.");
    };


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

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


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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: 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);


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr ? (
            lr
        ) : mf(lr.Right);

    return main();
})();

Thanks for reminding me about the plain copy.

The info is being pasted into Notes.

Though I do want to expand on that to only copy over the unchecked items

So a simple script to grab the unchecked rows, create a new Note and paste the result.

r

Here is one way of copying all unchecked rows in the front document (in tab-indented outline form)

We should be able to use .hasAttribute, but I seem to be hitting a type error with it, so in this draft using a fractionally more circuitous:

row.attributes.name().includes("data-done")

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

    // ------ UNCHECKED ROWS COPIED AS TAB-INDENTED ------

    const
        bike = Object.assign(
            Application("Bike"),
            { includeStandardAdditions: true }
        ),
        doc = bike.documents.at(0);

    const go = indent =>
        row => row.attributes.name().includes("data-done")
            ? []
            : [
                `${indent}${row.name()}`,
                ...(
                    row.containsRows()
                        ? row.rows().flatMap(go(`\t${indent}`))
                        : []
                )
            ];

    const tabbed = doc.exists()
        ? doc.rootRow.rows().flatMap(go(""))
            .join("\n")
        : "";

    return (
        bike.setTheClipboardTo(tabbed),
        tabbed
    );
})();
1 Like

that is quite cool :+1:

2 Likes

You will have spotted this, but for anyone who comes later, perhaps worth clarifying that, of course, any descendants of checked rows are also excluded by this draft.

If, for any reason, you preferred to retain unchecked descendants of checked items, you could sketch something simpler using an outline path like:

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

    // ------ UNCHECKED ROWS COPIED AS TAB-INDENTED ------
    //  ALTERNATIVE VERSION :: RETAINS UNCHECKED DESCENDANTS

    const
        bike = Object.assign(
            Application("Bike"),
            { includeStandardAdditions: true }
        ),
        doc = bike.documents.at(0);

    const tabbed = doc.exists()
        ? doc.query({ outlinePath: "//not @done" })
            .map(row =>
                `${"\t".repeat(row.level() - 1)}${row.name()}`
            )
            .join("\n")
        : "";

    return (
        bike.setTheClipboardTo(tabbed),
        tabbed
    );
})();

and once you are using outline paths, you can experiment with finer control, e.g.

"//* except //@done/descendant-or-self::*"
1 Like

And just to show that there are many ways to overthink this :slight_smile:, you can also use Bike’s outline paths to perform the traversal/filtering for you:

tell application "Bike"
	tell front document
		set theMatches to query outline path "//* except //@done///*"
		set theExport to export from theMatches as plain text format
		set the clipboard to theExport
	end tell
end tell

This is AppleScript, but you can use same query API in javascript. This method could be significantly faster (since query is all performed in Bike process), but it also means you get to/have to learn the outline path syntax :slight_smile:

Edit And somehow I missed that @complexpoint already mentioned outline paths in previous post!

1 Like

but I missed the trick of simplifying with .export, which, for the version excluding the whole subtree of any checked items, might, FWIW, look something like this in JS:

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

    // ------ UNCHECKED ROWS COPIED AS TAB-INDENTED ------
    //  ALTERNATIVE VERSION :: OMITS UNCHECKED DESCENDANTS

    const outlinePath = "//* except //@done///*";

    const
        bike = Object.assign(
            Application("Bike"),
            { includeStandardAdditions: true }
        ),

        doc = bike.documents.at(0),

        tabbed = doc.exists()
            ? doc.export({
                from: doc.query({ outlinePath }),
                as: "plain text format",
                all: false
            })
            : "";

    return (
        bike.setTheClipboardTo(tabbed),
        tabbed
    );
})();
2 Likes

:sunglasses:

@jessegrosjean and @complexpoint have just clarified to me the usefulness of outline paths and the pluses and minuses of both applescript and javascript.

Very fun overthinking!

2 Likes