Create a nested folder structure based on a plain text outline

Here is a macro for creating or updating a folder structure in the macOS file system.

Create folder structure from text outline.kmmacros (30.4 KB)

Untitled%205

USE

  1. Specify a path to an 'anchor' folder, in which the new directory structure will be either created or found and updated. (the anchor folder will be created if it doesn't yet exist)
  2. Provide a tab-indented plain text outline (directly as a Keyboard Maestro text variable, or perhaps read in from a plain text file.

And run the macro, to see a report tree on which every node is annoted as either 'Found' or 'Created'.

For example, given an anchor folder path:

varb

and a tab-indented outline:

we might obtain, on first run:

34

and if we run it again, all the nodes will be marked as 'Found' (already existing)

04

JS Source of the Execute a JavaScript for Automation action

/*
Copyright 2018 Rob Trew

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.

IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

// Create folder structure, at a given 'anchor' filePath,
// based on a supplied plain text outline.
// (must be consistently tab or 4-space indented)

// Ver 0.01
(() => {
    'use strict';

    // main :: IO String
    const main = () => {
        const
            kme = Application('Keyboard Maestro Engine'),
            fpAnchor = kme.getvariable('anchorFolderPath'),
            strOutline = kme.getvariable('folderOutline');
        return drawTree(
            fmapTree(
                fp => {
                    const lr = createDirectoryIfMissingLR(
                        true, fp
                    );
                    return lr.Left || lr.Right;
                },
                filePathTree(
                    fpAnchor,
                    treesFromLineIndents(
                        lineIndents(strOutline)
                    )
                )
            )
        );
    };

    // GENERIC FUNCTIONS --------------------------------------

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

    // Node :: a -> [Tree a] -> Tree a
    const Node = (v, xs) => ({
        type: 'Node',
        root: v, // any type of value (but must be consistent across tree)
        nest: xs || []
    });

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

    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = (a, b) => ({
        type: 'Tuple',
        '0': a,
        '1': b,
        length: 2
    });

    // append (++) :: [a] -> [a] -> [a]
    // append (++) :: String -> String -> String
    const append = (xs, ys) => xs.concat(ys);

    // concatMap :: (a -> [b]) -> [a] -> [b]
    const concatMap = (f, xs) => [].concat.apply([], xs.map(f));

    // 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(`Created: '${fp}'`)
            ) : Left(e.localizedDescription);
        })();

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

    // draw :: Tree String -> [String]
    const draw = node => {

        // shift :: String -> String -> [String] -> [String]
        const shift = (first, other, xs) =>
            zipWith(
                append,
                cons(first, replicate(xs.length - 1, other)),
                xs
            );
        // drawSubTrees :: [Tree String] -> [String]
        const drawSubTrees = xs => {
            const lng = xs.length;
            return lng > 0 ? (
                lng > 1 ? append(
                    cons(
                        '│',
                        shift('├─ ', '│  ', draw(xs[0]))
                    ),
                    drawSubTrees(xs.slice(1))
                ) : cons('│', shift('└─ ', '   ', draw(xs[0])))
            ) : [];
        };
        return append(
            lines(node.root),
            drawSubTrees(node.nest)
        );
    };

    // drawTree :: Tree String -> String
    const drawTree = tree =>
        unlines(draw(tree));

    // filePathTree :: filePath -> [Tree String] -> Tree filePath
    const filePathTree = (fpAnchor, trees) => {
        const go = fp => tree => {
            const path = `${fp}/${tree.root}`;
            return Node(
                path,
                tree.nest.map(go(path))
            );
        };
        return Node(fpAnchor, trees.map(go(fpAnchor)));
    };

    // filter :: (a -> Bool) -> [a] -> [a]
    const filter = (f, xs) => xs.filter(f);

    // fmapTree :: (a -> b) -> Tree a -> Tree b
    const fmapTree = (f, tree) => {
        const go = node => Node(
            f(node.root),
            node.nest.map(go)
        );
        return go(tree);
    };

    // foldl :: (a -> b -> a) -> a -> [b] -> a
    const foldl = (f, a, xs) => xs.reduce(f, a);

    // foldl1 :: (a -> a -> a) -> [a] -> a
    const foldl1 = (f, xs) =>
        xs.length > 1 ? xs.slice(1)
        .reduce(f, xs[0]) : xs[0];

    // isNull :: [a] -> Bool
    // isNull :: String -> Bool
    const isNull = xs =>
        Array.isArray(xs) || typeof xs === 'string' ? (
            xs.length < 1
        ) : undefined;

    // iterateUntil :: (a -> Bool) -> (a -> a) -> a -> [a]
    const iterateUntil = (p, f, x) => {
        let vs = [x],
            h = x;
        while (!p(h))(h = f(h), vs.push(h));
        return vs;
    };

    // levels :: Tree a -> [[a]]
    const levels = tree =>
        map(xs => map(x => x.root, xs),
            iterateUntil(
                xs => xs.length < 1,
                xs => concatMap(x => x.nest, xs), [tree]
            )
        );

    // lines :: String -> [String]
    const lines = s => s.split(/[\r\n]/);

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) => xs.map(f);

    // 'The mapAccumL function behaves like a combination of map and foldl;
    // it applies a function to each element of a list, passing an accumulating
    // parameter from left to right, and returning a final value of this
    // accumulator together with the new list.' (See Hoogle)
    // mapAccumL :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
    const mapAccumL = (f, acc, xs) =>
        xs.reduce((a, x, i) => {
            const pair = f(a[0], x, i);
            return Tuple(pair[0], a[1].concat(pair[1]));
        }, Tuple(acc, []));

    // minimum :: Ord a => [a] -> a
    const minimum = xs =>
        xs.length > 0 ? (
            foldl1((a, x) => x < a ? x : a, xs)
        ) : undefined;

    // readFile :: FilePath -> IO String
    const readFile = strPath => {
        let error = $(),
            str = ObjC.unwrap(
                $.NSString.stringWithContentsOfFileEncodingError(
                    $(strPath)
                    .stringByStandardizingPath,
                    $.NSUTF8StringEncoding,
                    error
                )
            );
        return Boolean(error.code) ? (
            ObjC.unwrap(error.localizedDescription)
        ) : str;
    };

    // replicate :: Int -> a -> [a]
    const replicate = (n, x) =>
        Array.from({
            length: n
        }, () => x);

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = (f, xs, ys) =>
        Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => f(xs[i], ys[i], i));

    // TREES FROM INDENTED TEXT

    // treesFromLineIndents :: [(Int, String)] -> [Tree String]
    const treesFromLineIndents = xs =>
        foldl((levels, tpl) => {
                const
                    indent = tpl[0],
                    iNext = indent + 1,
                    iMax = levels.length - 1,
                    node = Node(tpl[1]);
                return (
                    levels[
                        indent < iMax ? (
                            indent
                        ) : iMax
                    ].nest.push(node),
                    iNext > iMax ? (
                        levels.push(node)
                    ) : levels[iNext] = node,
                    levels
                );
            }, [Node(undefined, [])],
            xs
        )[0].nest;



    // lineIndents :: String -> [(Int, String)]
    const lineIndents = strIndented => {
        const
            rgxIndent = /^(\s*)(.*)$/,
            xs = concatMap(
                s => {
                    const
                        lr = s.match(rgxIndent).slice(1),
                        w = lr[1];
                    return isNull(w) ? (
                        []
                    ) : [Tuple(lr[0].length, w)];
                },
                lines(strIndented)
            ),
            indents = concatMap(x => {
                const n = x[0];
                return n > 0 ? [n] : [];
            }, xs),
            margin = minimum(indents),
            // Smallest non-zero indent difference
            // between any two adjacent lines

            unit = minimum(
                filter(
                    x => x > 0,
                    snd(mapAccumL(
                        (a, x) => Tuple(x, Math.abs(x - a)),
                        0, indents
                    ))
                )
            );
        return unit > 1 ? map(
            x => Tuple(
                Math.floor((x[0] - margin) / unit),
                x[1]
            ),
            xs
        ) : xs;
    };

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

4 Likes

PS for more fully-featured command line and LaunchBar versions of the same kind of thing, see also Brett Terpstra's Planter at http://brettterpstra.com/projects/planter/

Wow, that is some impressive code.
I'd like to use this but it fairly difficult to understand.
All I'm looking for is to replace "Found" with the name of the current folder (not parent or child, current).
so instead it would look like

extra
|------alpha
|-------beta

like a typical treeview. (I don't need the paths showing. Only the folder name.)
Any chance you could make a fix for me?

Ben

Not quite sure what you are really asking – the output of that macro is not a view, but a nest of newly created folders in the file system.

In any case, it happens to be a busy moment, so I'm afraid I'll have to quote this thread:


For a 'treeview', see man tree in the shell.

I don't understand what it's doing.

This is my approach:

  1. Snip them in VSCode. Put console.log() after each arrow function. Number them as well so I know the order of execution
  2. Put all into a file and step through it.
  3. Understand and Rename. Longer names if absolutely required.
  4. Look at the key responsibilties then compact them into key areas. From 28 arrow functions and still counting to something like 3-5 responsibilities.
  5. Attack until I got it working the way I need it. Keep what I need, discard the rest.
    Thanks,

Time to attack!

Nothing at all – it's pure functional code. No 'doing' and no 'responsabilities'.

But as I say, if it's a treeview that you need, then this is not the code you are looking for – the tree command in the shell is probably the place to start.

tree.pdf.zip (122.1 KB)

I was instead hoping to create a preview of folder treeview and then later create the directory structure once the folder directory structure has been confirmed.
The Tree option you gave me in that PDF reads an existing tree structure. The code you provided creates and previews an existing structure. Both of which are not the solution im quite looking for.

Ben

The text outline constitutes a preview,

and you can edit the text outline to the shape you want.

Once the text outline structure is finalized, the macro creates a corresponding folder structure in the file system.

No. It postviews the new structure – reports on what has been created by the script, or found by it where any part of the outlined structure already exists in the file system.

Updated code and simplified behaviour – simply displays a tree with full paths of the created (or found) nest of folders.

(Now makes no distinction, in the display, between 'found' and 'created' – some users may have found this confusing)

Nested folders created (or found) from text outline.kmmacros (14 KB)


Expand disclosure triangle to view JS Source
// Create a folder structure, at a given 'anchor' filePath,
// based on a supplied plain text outline.
// (must be consistently tab or 4-space indented)

// Ver 0.03
(() => {
    "use strict";

    // main :: IO String
    const main = () => {
        const
            kme = Application("Keyboard Maestro Engine"),
            kmVar = kme.getvariable;

        return drawTree(
            fmapTree(
                compose(
                    either(x => x)(x => x),
                    createDirectoryIfMissingLR(true)
                )
            )(
                filePathTree(kmVar("anchorFolderPath"))(
                    forestFromIndentedLines(
                        indentLevelsFromLines(
                            lines(kmVar("folderOutline"))
                        )
                    )
                )
            )
        );
    };

    // ------------ TREES FROM INDENTED TEXT -------------

    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree {text:String, body:Int}]
    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)
                    );

                return [
                        Node({
                            text: body,
                            level: depth
                        })(go(tree))
                    ]
                    // followed by the rest.
                    .concat(go(rest));
            })() : [];

        return go(tuples);
    };


    // indentLevelsFromLines :: [String] -> [(Int, String)]
    const indentLevelsFromLines = xs => {
        const
            pairs = xs.map(
                x => bimap(
                    cs => cs.length
                )(
                    cs => cs.join("")
                )(
                    span(isSpace)([...x])
                )
            ),
            indentUnit = pairs.reduce(
                (a, [i]) => 0 < i ? (
                    i < a ? i : a
                ) : a,
                Infinity
            );

        return [Infinity, 0].includes(indentUnit) ? (
            pairs
        ) : pairs.map(first(n => n / indentUnit));
    };


    // ---------------- GENERIC FUNCTIONS ----------------

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


    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => tpl => Tuple(f(tpl[0]))(
            g(tpl[1])
        );


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


    // createDirectoryIfMissingLR :: Bool -> FilePath
    // -> Either String FilePath
    const createDirectoryIfMissingLR = blnParents =>
        dirPath => {
            const fp = filePath(dirPath);

            return doesPathExist(fp) ? (
                Right(fp)
            ) : (() => {
                const
                    e = $(),
                    blnOK = $.NSFileManager
                    .defaultManager[
                        "createDirectoryAtPath" + (
                            "WithIntermediateDirectories"
                        ) + "AttributesError"
                    ](fp, blnParents, void 0, e);

                return blnOK ? (
                    Right(fp)
                ) : Left(e.localizedDescription);
            })();
        };


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


    // draw :: Tree String -> [String]
    const draw = node => {
        // shift :: String -> String -> [String] -> [String]
        const shifted = (first, other, xs) => (
            [first].concat(
                Array.from({
                    length: xs.length - 1
                }, () => other)
            ).map(
                (y, i) => y.concat(xs[i])
            )
        );
        // drawSubTrees :: [Tree String] -> [String]
        const drawSubTrees = xs => {
            const lng = xs.length;

            return 0 < lng ? (
                1 < lng ? (
                    ["│"].concat(
                        shifted("├─ ", "│  ", draw(xs[0]))
                    )
                ).concat(
                    drawSubTrees(xs.slice(1))
                ) : ["│"].concat(
                    shifted("└─ ", "   ", draw(xs[0]))
                )
            ) : [];
        };

        return node.root.split("\n").concat(
            drawSubTrees(node.nest)
        );
    };


    // drawTree :: Tree String -> String
    const drawTree = tree =>
        draw(tree).join("\n");


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


    // filePath :: String -> FilePath
    const filePath = s =>
        // The given file path with any tilde expanded
        // to the full user directory path.
        ObjC.unwrap(
            ObjC.wrap(s)
            .stringByStandardizingPath
        );


    // filePathTree :: filePath -> [Tree String] -> Tree FilePath
    const filePathTree = fpAnchor => trees => {
        const go = fp => tree => {
            const path = `${fp}/${tree.root.text}`;

            return Node(path)(
                tree.nest.map(go(path))
            );
        };

        return Node(fpAnchor)(
            trees.map(go(fpAnchor))
        );
    };


    // first :: (a -> b) -> ((a, c) -> (b, c))
    const first = f =>
        // A simple function lifted to one which applies
        // to a tuple, transforming only its first item.
        ([x, y]) => [f(x), y];


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

        return go;
    };


    // isSpace :: Char -> Bool
    const isSpace = c =>
        // True if c is a white space character.
        (/\s/u).test(c);


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


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


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


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


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