Copy Selected Folder(s) and Their Descendants as a Plain Text Outline

This macro is dual to (a mirror image of – moving in the opposition direction) another macro on this forum:

[Create a nested folder structure based on a plain text outline](Create a nested folder structure based on a plain text outline)


This one lets you:

  • Select one or more folders in Finder,
  • displaying, and copying to Clipboard, a plain text (tab-indented, so TaskPaper-compatible) outline of the folder sub-structure,
  • either showing or hiding (depending on an option setting) the non-folder files in each directory.

Copy selected folder(s) and their descendants as a plain text outline.kmmacros (30 KB)

JS Source
(() => {
    'use strict';

    ObjC.import('AppKit');

    // Plain text outline of selected folders and
    // their descendants (copied to clipboard).

    // Rob Trew @2020
    

    // --------------------- OPTION ----------------------

    // Include all visible files ?
    // or just folders ?
    const
        foldersOnly = ['1', 'true'].includes(
            Application('Keyboard Maestro Engine')
            .getvariable('listFoldersOnly')
            .trim()
            .toLowerCase()
        ),
        pFilter = foldersOnly ? (
            fp => doesDirectoryExist(fp)
        ) : fp => !takeFileName(fp).startsWith('.');


    // ---------------------- MAIN -----------------------

    // main :: IO ()
    const main = () => {
        const
            selectedFolders = Application(
                'Finder'
            ).selection();
        return bindLR(
            undefined !== selectedFolders ? (
                Right(
                    selectedFolders.flatMap(
                        x => filePathForest(
                            decodeURI(x.url()).slice(7)
                        )
                    )
                )
            ) : Left('Nothing selected in Finder.')
        )(
            compose(
                copyText,
                outlineFromForest('\t')(x => {
                    const
                        xs = x.split('/'),
                        final = last(xs);
                    return Boolean(final) ? (
                        final
                    ) : xs[xs.length - 2];
                }),
                nest,
                foldTree(
                    x => xs => Node(x)(
                        sortBy(comparing(root))(xs)
                    )
                ),
                Node(''),
                map(filteredTree(pFilter))
            )
        );
    };


    // filePathForest :: FilePath -> [Tree FilePath]
    const filePathForest = fp => {
        const go = fp =>
            Node(fp)(
                doesDirectoryExist(fp) ? (
                    map(compose(go, combine(fp)))(
                        getDirectoryContents(fp)
                    )
                ) : []
            );
        return doesPathExist ? [go(fp)] : [];
    };


    // outlineFromForest :: String ->
    // (a -> String) -> [Tree a] -> [String]
    const outlineFromForest = unitIndent =>
        // Indented text representation of a list of 
        // Trees.
        // f is an (a -> String) function defining
        // the string representation of tree nodes.
        f => trees => {
            const go = indent => x => {
                const
                    s = indent + f(x.root),
                    xs = x.nest,
                    nextDepth = unitIndent + indent;
                return 0 < xs.length ? (
                    [s].concat(
                        xs.flatMap(go(nextDepth))
                    )
                ) : s;
            };
            return unlines(trees.flatMap(go('')));
        };


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

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


    // doesDirectoryExist :: FilePath -> IO Bool
    const doesDirectoryExist = fp => {
        const ref = Ref();
        return $.NSFileManager.defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp)
                .stringByStandardizingPath, ref
            ) && ref[0];
    };


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


    // fileStatus :: FilePath -> Either String Dict
    const fileStatus = fp => {
        const
            e = $(),
            dct = $.NSFileManager.defaultManager
            .attributesOfItemAtPathError(
                ObjC.wrap(fp).stringByStandardizingPath,
                e
            );
        return dct.isNil() ? (
            Left(ObjC.unwrap(e.localizedDescription))
        ) : Right(ObjC.deepUnwrap(dct));
    };


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


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


    // combine (</>) :: FilePath -> FilePath -> FilePath
    const combine = fp =>
        // Two paths combined with a path separator. 
        // Just the second path if that starts 
        // with a path separator.
        fp1 => Boolean(fp) && Boolean(fp1) ? (
            '/' === fp1.slice(0, 1) ? (
                fp1
            ) : '/' === fp.slice(-1) ? (
                fp + fp1
            ) : fp + '/' + fp1
        ) : fp + fp1;


    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        x => y => {
            const
                a = f(x),
                b = f(y);
            return a < b ? -1 : (a > b ? 1 : 0);
        };


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


    // filteredTree (a -> Bool) -> Tree a -> Tree a
    const filteredTree = p =>
        // A tree including only those children
        // which either match the predicate p, or have
        // descendants which match the predicate p.
        foldTree(x => xs =>
            Node(x)(xs.filter(
                tree => (0 < tree.nest.length) || (
                    p(tree.root)
                )
            ))
        );


    // 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(tree.root)(
            tree.nest.map(go)
        );
        return go;
    };


    // getDirectoryContents :: FilePath -> IO [FilePath]
    const getDirectoryContents = fp =>
        ObjC.deepUnwrap(
            $.NSFileManager.defaultManager
            .contentsOfDirectoryAtPathError(
                $(fp)
                .stringByStandardizingPath, null
            )
        );


    // last :: [a] -> a
    const last = xs => (
        // The last item of a list.
        ys => 0 < ys.length ? (
            ys.slice(-1)[0]
        ) : undefined
    )(list(xs));


    // list :: StringOrArrayLike b => b -> [a]
    const list = xs =>
        // xs itself, if it is an Array,
        // or an Array derived from xs.
        Array.isArray(xs) ? (
            xs
        ) : Array.from(xs || []);


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


    // nest :: Tree a -> [a]
    const nest = tree => {
        // Allowing for lazy (on-demand) evaluation.
        // If the nest turns out to be a function –
        // rather than a list – that function is applied
        // here to the root, and returns a list.
        const xs = tree.nest;
        return 'function' !== typeof xs ? (
            xs
        ) : xs(root(x));
    };


    // root :: Tree a -> a
    const root = tree =>
        tree.root;


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


    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
        xs => list(xs).slice()
        .sort((a, b) => f(a)(b));


    // takeFileName :: FilePath -> FilePath
    const takeFileName = fp =>
        '' !== fp ? (
            '/' !== fp[fp.length - 1] ? (
                fp.split('/').slice(-1)[0]
            ) : ''
        ) : '';


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