Table of Contents (via bike command line)

If you have heading rows (Format > Row > Heading), at various levels of indentation in the outline you are editing, you can obtain a simple TOC (tab-indented sub-headings, in this draft) with the bike command line.

( Bike 2.0 Preview (283+) > Install Command Line Tool)

Assuming that you have also installed jq
(for example via brew)

In Terminal.app:

bike get outline | jq -r 'def toc(level):
  if .type == "heading" then
    ( ("\t" * level) + (.text | map(.string) | join("")) ),

    (.children[]? | toc(level + 1))
  else
    (.children[]? | toc(level))
  end;

.root | toc(0)'
2 Likes

can we have a limit for heading levels?

1 Like

In jq terms:

def toc(level):
    if $ARGS.named.max != null and level >= $ARGS.named.max then
      empty
      
    elif "heading" == .type then
      ( ("\t" * level) + (.text | map(.string) | join("")) ),
      (.children[]? | toc(level + 1))
      
    else
      (.children[]? | toc(level))

    end;

.root | toc(0)

and we can bind the name max to a number at the command line with an --argjson switch,
so starting with the copy button at top-right in the code field below,
and pasting at the Terminal.app prompt:

bike get outline | jq -r --argjson max 3 '
def toc(level):
    if $ARGS.named.max != null and level >= $ARGS.named.max then
      empty

    elif "heading" == .type then
      ( ("\t" * level) + (.text | map(.string) | join("")) ),
      (.children[]? | toc(level + 1))

    else
      (.children[]? | toc(level))

    end;

.root | toc(0)'

thanks! will try

1 Like

I’ve now slightly amended the parameterised code above to make the --argson switch optional.

If you simply omit it the binding of a value to max, you should now get a full-depth TOC.

When you change the value given to --argjson max ..., or omit the --argson switch entirely, always remember to leave a separating space character before the opening single quote around the jq code.

1 Like

or, of course, as a nested MD list with bike:// links back to each header row:

Expand disclosure triangle to view JQ source
.persistentId as $doc_id |

def toc(level):
  if $ARGS.named.max != null and level >= $ARGS.named.max then
    empty

  elif "heading" == .type then
    (.text | map(.string) | join("")) as $label |

    ( ("    " * level) + "- [\($label)](bike://\($doc_id)/\(.persistentId))" ),

    (.children[]? | toc(level + 1))

  else
    (.children[]? | toc(level))
  end;

.root | toc(0)

Expand disclosure triangle to view full command line
bike get outline | jq -r --argjson max 3 '.persistentId as $doc_id |

def toc(level):                                               
  if $ARGS.named.max != null and level >= $ARGS.named.max then
    empty
  
  elif "heading" == .type then                   
    (.text | map(.string) | join("")) as $label |                              

    ( ("    " * level) + "- [\($label)](bike://\($doc_id)/\(.persistentId))" ),
    
    (.children[]? | toc(level + 1))
  
  else                         
    (.children[]? | toc(level))
  end;

.root | toc(0)' 

Though that will only be helpful if each header already has a .persistentId :thinking:


( I understand there may be plans to enable the bike update command to generate .persistentId values for any rows – on a given outline path – that don’t yet have them, so it may become possible to add that to the command line, and ensure a properly linked TOC )

1 Like

In the meanwhile, FWIW, the extension scripting context can already ensure that a node has a .persistentId, and we can access that context through JXA.

Not as succinct or light-weight as the command line, but should also work well.

The following draft,

(you can experiment in Script Editor with the language selector at top-left set to JavaScript, rather than AppleScript),

has two options which you can set at the top, adjusting the depth and format of the TOC:

const options = {
    maxLevel: 6,
    format: unorderedWithLinks
};

and copies a TOC for the active outline (if it contains heading rows) to clipboard.

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

    // Copy a TABLE OF CONTENTS for the front Bike outline.

    // Rob Trew @2026
    // Ver 0.5

    // ------------------- TOC FORMATS -------------------

    // {path::[Int], level::Int, text:String, html:String, url:String}
    // ->  String

    // Where:
    //  `path` is a list of zero-based sibling indices
    //  `level` is a zero-based index (top level is 0)
    //  `text` and `html` are alternative string formats
    //  `url` is a `bike://` link back to the heading in the document.

    const legalNumbering = x =>
        `${"   ".repeat(x.level)}${x.path.map(v => 1 + v).join(".")} ${x.text}`;

    const unorderedWithLinks = x =>
        `${"    ".repeat(x.level)}- [${x.text}](${x.url})`;

    const multipleHash = x =>
        `${"#".repeat(1 + x.level)} ${x.text}`;


    // --------------------- OPTIONS ---------------------
    const options = {
        maxLevel: 6,
        format: unorderedWithLinks
    };


    // The TOC is derived from any heading rows in the outline.
    // ( Bike > Format > Row > Heading )
    // and the heading level is a function of how many
    // heading ancestors a given heading has in the outline.

    // OPTIONS (set in the dictionary above)
    // - You can restrict the depth of headings used by changing
    //   the integer value of `options.maxLevel`

    // - If options.f is deleted from the options dictionary above,
    //   or given a value which is not a function, then
    //   the format of the TOC will default to a plain
    //   outline indented by four spaces at each level
    //   and with a " -".
    //   f can, however, be a function over (x, i)
    //   in which `x` is a dictionary with the keys:

    //   {path::[Int], level::Int, text::String, html::String, url::String}

    //   where `path` is an ancestral chain of zero-based peer indices, 

    //   and `i` is the immediate zero-based peer index
    //   (also included as the last item of `path`)

    //   f can have the type (Dict, Int) -> String
    //   or simply (Dict -> String)

    //   For example to give the TOC an MD hash level format,
    //   you could specify:
    //    f: x => `${"#".repeat(1 + x.level)} ${x.text}`

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

    ObjC.import("AppKit");

    const main = () => {
        const
            input = JSON.stringify(options),
            bike = Application("Bike"),
            render = ("function" === typeof options.format && options.format) || (
                unorderedWithLinks
            );

        return either(
            alert("Copy TOC for front Bike document")
        )(
            copyText
        )(
            bike.documents.at(0).exists()
                ? fmapLR(
                    outlineFromForest(render)
                )(
                    jsonParseLR(
                        bike.evaluate({ script, input })
                    )
                )
                : Left("No document open in Bike.")
        );
    };

    // ------------------ RENDERING ------------------

    // outlineFromForest :: String ->
    // (a -> String) -> [Tree a] -> [String]
    const outlineFromForest = f =>
        // Indented text representation of a list of Trees.
        // f is an (a -> String) function defining
        // the string representation of a tree node.
        trees => {
            const go = (x, i) => [
                f(x.root, i),
                ...x.nest.flatMap(go)
            ];

            return trees.flatMap(go)
                .join("\n");
        };


    // ------------- BIKE EXTENSION CONTEXT --------------
    const script = (inputJSON => {
        const { maxLevel, withLinks } = JSON.parse(inputJSON);


        // ------------------ BIKE MAIN ------------------
        const bikeMain = () => {
            const
                deepest = maxLevel || Infinity,
                root = bike.frontmostOutlineEditor.outline.root,
                docId = root.persistentId;

            return JSON.stringify(
                forestWithPositions(
                    forestFromItemLevels(
                        headings(docId)(0)(deepest)(root)
                    )
                )
                    .map(fmapTree(
                        ([path, dict]) => Object.assign({ path }, dict)
                    )),
                null, 2
            );
        };

        // --------------------- TOC ---------------------

        // forestWithPositions :: [Tree a] -> [Tree ([Int], a)]
        const forestWithPositions = forest => {
            const go = path =>
                xs => xs.map(treeWithPositions(path));

            return go([])(forest);
        };

        // treeWithPositions :: [Int] -> (Tree a, i) -> Tree ([Int], a)
        const treeWithPositions = ancestralPath =>
            (tree, index) => {
                const fullPath = ancestralPath.concat(index);

                return Node(
                    Tuple(fullPath)(
                        root(tree)
                    )
                )(
                    nest(tree).map(
                        treeWithPositions(fullPath)
                    )
                );
            };

        // headings :: String -> Int -> Row -> [{level::Int, text::String, HTML::String}]
        const headings = docId =>
            nLevel => maxDepth => row => {
                const go = level =>
                    row => level < maxDepth && "heading" === row.type
                        ? [
                            {
                                level,
                                text: row.text.string,
                                html: row.text.toHTML(),
                                url: `bike://${docId}/${row.persistentId}`
                            },
                            ...row.children.flatMap(go(1 + level))
                        ]
                        : row.children.flatMap(go(level));

                return go(nLevel)(row);
            };

        // -------------- FOREST STRUCTURE ---------------

        // forestFromLevels :: Int [{level::Int ...}] -> 
        //  [Tree {level::Int ...}]
        const forestFromItemLevels = dicts => {
            const go = xs =>
                0 < xs.length
                    ? (() => {
                        // First line and its sub-tree,
                        const
                            item = xs[0],
                            level = item.level,
                            [tree, rest] = span(
                                x => level < x.level
                            )(
                                xs.slice(1)
                            );

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

            return go(dicts);
        };

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

        // 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 => ({
                root: v,
                nest: xs || []
            });

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

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

        // nest :: Tree a -> [a]
        const nest = tree =>
            tree.nest;

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

    // ------------------- JXA CONTEXT -------------------

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


    // copyText :: String -> IO String
    const copyText = s => {
        // ObjC.import("AppKit");
        const pb = $.NSPasteboard.generalPasteboard;

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

    // -------------------- GENERICS ---------------------

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


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if it is a Left value.
        e => "Left" in e
            ? e
            : Right(f(e.Right));


    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(
                [
                    e.message,
                    `(line:${e.line} col:${e.column})`
                ].join("\n")
            );
        }
    };


    return main();
})();
1 Like

Updated above to allow for custom TOC patterns

  • legal numbering
  • simple ordered or unordered MD lists with bike:// links
  • unindented with multiple # prefixes
  • etc