Scripting Find/Replace

Is scripting find/replace possible at this point?

Bike doesn’t provide a built in API to do this. Two options I see are:

  1. Perform find and replace by iterating over all rows in the outline and getting row text, performing replace, and then setting row text.

  2. Use UI scripting to present the find panel and do what you want.

What are you trying to do?

I’ll regularly be copy/pasting sections from OmniOutliner documents. To clean them up, I want to run a find (regex) “^-\s|^*\s” and replace with nothing. (It can be run on the whole document or just the pasted section.) In an ideal world, I’d create an “Omni Paste” script that pastes the clipboard contents and then runs this find/replace.

Thanks.

pastes the clipboard contents and then runs this find/replace.

Another approach might be a Copy As Bike script for OmniOutliner – or a Paste without bullets for Bike – allowing you to paste straight into Bike without further processing.

Am I right in thinking that you essentially need to remove bullets from the OmniOutliner clipboard ?

You could experiment with something like this, for example:

Paste outline without bullets.kmmacros.zip (2.6 KB)

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

    ObjC.import("AppKit");

    // Bullets deleted from plain text outline in clipboard.

    // Rob Trew @2022
    // Ver 0.01

    const clipType = "public.utf8-plain-text";

    const main = () => {
        const bullet = /^(\s*)- /ug;

        return either(
            alert("Clear bullets from clipoard")
        )(
            x => x
        )(
            bindLR(
                clipOfTypeLR(clipType)
            )(
                txt => Right(
                    setClipOfTextType(clipType)(
                        unlines(
                            lines(txt).map(
                                x => x.replace(bullet, "$1")
                            )
                        )
                    )
                )
            )
        );
    };


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


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


    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };


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


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


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single string
        // which is delimited by \n or by \r\n or \r.
        Boolean(s.length) ? (
            s.split(/\r\n|\n|\r/u)
        ) : [];


    // unlines :: [String] -> String
    const unlines = xs =>
        // A single string formed by the intercalation
        // of a list of strings with the newline character.
        xs.join("\n");


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

(For Script Editor testing, set the language selector at top left to JavaScript)

Using Scripts - Bike

Thanks for this! But I’m totally lost as to how to use it. I can’t compile it or any subset of it that I can come up with in the script editor (set to javascript). Can you give me any hints?

If you have the Script Editor language selector set to JavaScript,

and compilation is getting interrupted, then the usual problem is that you haven’t quite copied all of the source text behind the disclosure triangle above.

Make sure that you have copied and pasted all 149 lines of the source code, from:

(() => {
    "use strict";

scrolling down to:

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

I notice also that you refer to the script editor rather than to “Script Editor”.

If you are not using Apple’s Script Editor.app, but something like Visual Studio Code, then you need to specify, and compile against JavaScript For Automation (sometimes called JXA) rather than just JavaScript. (In the case of VSC that will require a plugin like Code Runner)

(The code uses ObjC libraries only available to a JXA (osascript) instance of a JavaScript interpreter)


Finally, to avoid the need for compilation, you could use the Keyboard Maestro macro version above (with a trial version of Keyboard Maestro, if you don’t have the full version).

OK, I see what the problem was. I was opening the “Paste outline without bullets.kmmacros” file you provided with a text editor, copying the script text from the middle of the file, and attempting to compile that. The problem was that in that file the < and > characters were represented by their HTML codes, and that was causing the compiler to throw up. Once I replaced the HTML codes with < and >, it compiled OK. (Were you assuming I was going to open the file in something other than a simple text editor?)

It works great! I found another issue I’ll have to deal with, but I think starting with your script I can figure that out.

In the meantime, I approached it another way: I created an AppleScript file that used the “Copy As Bike” from OmniOutliner approach you suggested. The AppleScript does the copy and then runs a shell script that uses sed to do the find/replaces. (You can actually access the clipboard from a shell script–something I didn’t know before.)

If I want to implement the the “Copy As Bike” approach using your script, what’s the best way to do it? Same thing I’ve just described but have the AppleScript call the JavaScript instead of the shell script?

Thanks again.

Bob

Good to hear.

Don’t hesitate to tell us.

Various approaches – you could:

  • capture an OmniOutliner clipboard and transform that (easier with the built-in copy, paste, and menu choice actions built into Keyboard Maestro),
  • or you could script a direct reading of the lines selected in OmniOutliner, and directly generate a bullet-free tab-indented outline for pasting into Bike, and automate the placing of that in the clipboard.

For a JavaScript for Automation approach to the latter, copying an OO selection like:

to a bullet-free tab-indented clipboard for Bike-pasting like:

Epsilon
	Zeta
	Eta
		Theta
		Iota
		Kappa

you could try something like:

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

    ObjC.import("AppKit");

    // "Copy As Bike" for OmniOutliner.

    // Places a tab-indented (bullet-free) outline in the
    // public.utf8-plain-text clipboard.

    // Rob Trew @2020
    // Ver 0.01

    // main :: IO ()
    const main = () => {
        const
            oo = Application("OmniOutliner"),
            doc = oo.documents.at(0);

        return either(
            alert("Copy as Bike")
        )(
            x => x
        )(
            doc.exists() ? (() => {
                const
                    selectedRows = doc.rows.where({
                        selected: true
                    }),
                    levels = selectedRows.level(),
                    n = levels.length,
                    minLevel = Math.min(...levels) - 1;

                return Right(
                    (
                        setClipOfTextType(
                            "public.utf8-plain-text"
                        )(
                            tabIndentedFromForest(
                                forestFromIndentedLines(
                                    zip(
                                        levels.map(
                                            x => x - minLevel
                                        )
                                    )(
                                        selectedRows.name()
                                    )
                                )
                            )
                        ),
                        `${n} row(s) copied to outline with no bullets.`
                    )
                );

            })() : Left("No documents open in OmniOutliner")
        );
    };

    // -------- TAB-INDENTED OUTLINE FROM FOREST ---------

    // tabIndentedFromForest :: [Tree String] -> String
    const tabIndentedFromForest = forest => {
        const go = indent =>
            tree => {
                const xs = tree.nest;

                return [`${indent}${tree.root}`].concat(
                        Boolean(xs.length) ? (
                            xs.map(go(`\t${indent}`))
                        ) : []
                    )
                    .join("\n");
            };

        return forest.map(go("")).join("");
    };

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

    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };

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

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


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


    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree String]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const [depth, body] = xs[0],
                    [tree, rest] = span(x => depth < x[0])(
                        xs.slice(1)
                    );

                // followed by the rest.
                return [
                    Node(body)(go(tree))
                ].concat(go(rest));
            })() : [];

        return go(tuples);
    };


    // span :: (a -> Bool) -> [a] -> ([a], [a])
    const span = p =>
        // Longest prefix of xs consisting of elements which
        // all satisfy p, tupled with the remainder of xs.
        xs => {
            const i = xs.findIndex(x => !p(x));

            return -1 !== i ? (
                Tuple(xs.slice(0, i))(
                    xs.slice(i)
                )
            ) : Tuple(xs)([]);
        };


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
        // The paired members of xs and ys, up to
        // the length of the shorter of the two lists.
        ys => Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => Tuple(xs[i])(ys[i]));

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

(Which I would personally paste into a Keyboard Maestro Execute JavaScript for Automation action, bound to a convenient keystroke)

Copy as Bike.kmmacros.zip (3.3 KB)