Jim Harrison's Project Folder script not working in OF2

Not using OF myself these days, but FWIW my partner

  • upgraded to Mojave yesterday,
  • found that OF1 no longer worked,
  • also found that she needed to temporarily install OF2 to migrate the data,
  • and finally discovered that the version of Jim Harrison’s script which she had been using didn’t work with OF3.

Here is a first draft of a JavaScript for Automation replacement.

At the top of the script, you need to specify a path to the root folder in the file system which corresponds to the top level of your OF3 sidebar.

The default is: ~/Documents/projects

Health warning – I hope that this might be at least a useful reference or resource for someone, but I’m not yet sure how much it will be supported. It will be supported if my partner continues to manage her work with OF3, which she may well do, but she’s also thinking about migrating the case folders over to the new version of DEVONThink 3, in which case there won’t be a lot of OmniFocus activity in this household.

Finally, remember that if you do save a copy of this from Script Editor to

~/Library/Application Scripts/com.omnigroup.OmniFocus3,

(to experiment with using it in the OF3 toolbar) you will need to ensure that the language tab at top left of Script Editor is set to JavaScript, rather than AppleScript., and use Save As to a .scpt format file.

(() => {
    'use strict';

    // RobTrew @complexpoint 2019
    // https://github.com/RobTrew
    // Ver 0.01


    // fpRoot :: FilePath
    // Folder path for the top level of the Omnifocus 3 sidebar
    const fpRoot = '~/Documents/projects';

    // Finder folder opened or created for
    // the first (if any) selected project/folder in
    // OmniFocus 3

    // main :: IO ()
    const main = () => {
        const
            of3 = Application('OmniFocus'),
            sa = (
                of3.includeStandardAdditions = true,
                of3
            ),
            ws = of3.documents.at(0).documentWindows;

        return bindMay(
            ws.length ? Just(ws.at(0)) : Nothing(),
            w => {
                const sidebar = w.sidebar;
                return bindMay(
                    maybeSelection(w)(sidebar),
                    trees => bindMay(
                        pathFoundorCreated(sa)(
                            filePathFromSidebar(fpRoot)(
                                sidebar
                            )(trees)
                        ),
                        openedFolder
                    )
                );
            }
        );
    };

    // OF PROJECT FOLDER ----------------------------------

    // maybeSelection :: OF Doc Win -> sidebar -> Maybe trees
    const maybeSelection = w => sidebar =>
        find((x => 0 < x.length), [
            sidebar.selectedTrees,
            w.content.selectedTrees
        ]);

    // openedFolder :: FilePath -> Maybe FilePath
    const openedFolder = fp => {
        const finder = Application('Finder');
        finder.open(Path(fp));
        finder.activate();
        return Just(fp);
    };

    // pathFoundorCreated :: Standard Additions ->
    //                       FilePath -> Maybe FilePath
    const pathFoundorCreated = sa => fpPath =>
        doesPathExist(fpPath) ? (
            Just(fpPath)
        ) : (() => {
            sa.activate();
            return either(
                x => Nothing(),
                x => (
                    createDirectoryIfMissingLR(
                        true, fpPath
                    ),
                    Just(fpPath)
                ),
                dialogChoiceLR(sa)(
                    'No existing project folder found',
                    'Create new folder in Finder at:\n\n' +
                    ObjC.unwrap(
                        $(fpPath)[
                            'stringByAbbreviating' +
                            'WithTildeInPath'
                        ]
                    ) +
                    '\n\n?',
                    Left(''),
                    ['Cancel', 'OK'], 'OK', 'Cancel',
                    30
                )
            );
        })();


    // selnProjectId :: OF Node -> ID String
    const selnProjectId = node => {
        const props = node.properties();
        return ['project', 'folder']
            .includes(props.pcls) ? (
                props.id
            ) : props.containingProject.id();
    };

    // filePathFromSidebar :: FilePath ->
    //      OF Selected Trees -> FilePath
    const filePathFromSidebar = fpRoot => sidebar => trees =>
        filePath(fpRoot) + '/' +
        treePath(
            sidebar.descendantTrees.byId(
                selnProjectId(
                    trees.at(0).value()
                )
            )
        ).join('/');

    // treePath :: OF Node -> [String]
    const treePath = node => {
        // In case any node names contain '/'
        const normalized = s =>
            s.replace(/\//g, ':')
        return tail(reverse(cons(
            normalized(node.name()),
            map(t => normalized(t.name() || ''),
                node.ancestorTrees()
            )
        )));
    };

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

    // Nothing :: Maybe a
    const Nothing = () => ({
        type: 'Maybe',
        Nothing: true,
    });

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

    // bindMay (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
    const bindMay = (mb, mf) =>
        mb.Nothing ? mb : mf(mb.Just);

    // cons :: a -> [a] -> [a]
    const cons = (x, xs) => [x].concat(xs);

    // createDirectoryIfMissingLR :: Bool -> FilePath ->
    //                               Either String String
    const createDirectoryIfMissingLR = (blnParents, fp) =>
        doesPathExist(fp) ? (
            Right(`Found: '${fp}'`)
        ) : (() => {
            const
                e = $(),
                blnOK = $.NSFileManager.defaultManager[
                    'createDirectoryAtPath' +
                    'WithIntermediateDirectoriesAttributesError'
                ]($(fp)
                    .stringByStandardizingPath,
                    blnParents, undefined, e
                );
            return blnOK ? (
                Right(fp)
            ) : Left(e.localizedDescription);
        })();

    // dialogChoiceLR :: String -> String -> Either String String ->
    //      [String] -> String -> String -> Int -> FilePath
    //          -> Either Dict String
    const dialogChoiceLR = sa => (
        strTitle, strMsg, lrDefault, lstButtons, strDefaultButton,
        strCancelButton, intMaxSeconds, strIconPath
    ) => {
        try {
            sa.activate;
            return (() => {
                // sa :: standardAdditions
                const dct = sa.displayDialog(strMsg, Object.assign({
                    buttons: lstButtons || ['Cancel', 'OK'],
                    defaultButton: strDefaultButton || 'OK',
                    cancelButton: strCancelButton || 'Cancel',
                    withTitle: strTitle,
                    givingUpAfter: intMaxSeconds || 120
                }, isRight(lrDefault) ? {
                    defaultAnswer: lrDefault.Right
                } : {}, typeof strIconPath === 'string' ? {
                    withIcon: Path(strIconPath)
                } : {}));
                return dct.gaveUp ? (
                    Left(dct)
                ) : Right(dct.textReturned);
            })();
        } catch (e) {
            return Left(e);
        }
    };

    // doesPathExist :: FilePath -> IO Bool
    const doesPathExist = strPath =>
        $.NSFileManager.defaultManager
        .fileExistsAtPath(
            $(strPath).stringByStandardizingPath
        );

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

    // filePath :: String -> FilePath
    const filePath = s =>
        ObjC.unwrap(ObjC.wrap(s)
            .stringByStandardizingPath);

    // find :: (a -> Bool) -> [a] -> Maybe a
    const find = (p, xs) => {
        for (let i = 0, lng = xs.length; i < lng; i++) {
            const v = xs[i];
            if (p(v)) return Just(v);
        }
        return Nothing();
    };

    // isRight :: Either a b -> Bool
    const isRight = lr =>
        ('undefined' !== typeof lr) &&
        ('Either' === lr.type) && (undefined !== lr.Right);

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) =>
        (Array.isArray(xs) ? (
            xs
        ) : xs.split('')).map(f);

    // reverse :: [a] -> [a]
    const reverse = xs =>
        'string' !== typeof xs ? (
            xs.slice(0).reverse()
        ) : xs.split('').reverse().join('');

    // showLog :: a -> IO ()
    const showLog = (...args) =>
        console.log(
            args
            .map(JSON.stringify)
            .join(' -> ')
        );

    // tail :: [a] -> [a]
    const tail = xs => 0 < xs.length ? xs.slice(1) : [];

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

3 Likes