Script to prepend outline prefixes

I used to use the app “Tree 2” to do my outlining, but I found that TaskPaper does everything I need and more. One thing I was never completely happy about “Tree” was that when exporting into a text file (I use that to print), the only option available was to to prepend “numbering” to the beginning of the lines. I was wondering if it was possible to accomplish something more complex than this with a script. Have a couple of list like, A-Z, a-z, 1-…, I-…, etc and then prepend those to the beginning of the lines.

One possibility is to experiment with printing through Marked 2

which has an outline mode, in which you can choose two numbering styles for printing

I have just updated the script at:

Thanks, that looks good.

Here is a draft script which copies an outline-numbered version of the front TaskPaper 3 document to the clipboard.

It’s mainly just an experiment in using Pashua.app (see bluem.net) with JavaScript for Automation bindings to generate custom dialogs for TaskPaper 3 scripts – but it’s possible that it may do some of what you want. You can edit the numbering pattern to use mixtures of upper and lower case alphabetics, or upper and lower case Roman numerals.

The number or symbol used starts the series, so for a variant of ISO 2145 which uses zero for the Introduction, you would use something like 0.1.1.1 (depending on the depth of outline numbering that you want). You can also use spaces or one or more other punctuation characters in place of dots.

If you are always going to use the same settings, the script can be used without Pashua.app or the dialog, by manually editing the options at the end.

Installation:

// Copy TaskPaper Document (or selected section)
// as numbered text

// Author: Rob Trew   Twitter: @complexpoint
// Ver : 0.03

// 0.03 Proper handling of Cancel button
// 0.02 Minor edits - fixed a tooltip, added a reference to the bindings at
//      https://github.com/RobTrew/Pashua-Binding-JXA

// License MIT

// DESCRIPTION
// Copies document or selected section to clipboard as numbered text
// Initial defaults (see end of script) are:
//  - 1-based ISO 2145  (1.1.1) numbering of whole document
//  - (with outline tab-indentation preserved)

// If Carsten Blum's Pashua.app is installed in the Applications folder
// the script displays a dialog with various options

// The basic 1.1.1 pattern can be varied by replacing

//  1. The numeration symbols
//      0 or any other number will start a level series with that number
//      Alphabetic characters can be used at any level in place of numbers
//      i or I will be interpreted as specifying roman numerals

//  2. The delimiters
//      Dots can be replaced by spaces, and/or one or more other \W characters
//      Trailling dots can be suppressed (as per ISO 2145)
//      with the option pruneLastDot:true   (or edit to false)

//  3. The outline indents
//      Numbered text can be left-aligned or outline-indented.
//      The relevant option is preserveIndents:true   (or edit to false)

// All of these options can be chosen either through the Pashua.app dialog
// or by manually editing the options at the end of the script.

(function (dctDefaults) {

    // TASKPAPER CONTEXT
    function getSelectionRoot(editor, options) {

        // selectionRoots :: editor -> maybe [Item]
        function selectionRoots(selection) {
            var lngSeln = selection.selectedItems.length,
                oStart = selection.startItem;

            return lngSeln === 1 ? (
                oStart.hasChildren ? [oStart] : [oStart.parent]
            ) : (lngSeln > 1 ? (
                selection.selectedItemsCommonAncestors
                .filter(function (x) {
                    return x.hasChildren || x.bodyString !== '';
                })
            ) : undefined);
        }

        var outline = editor.outline,
            selnRoots = selectionRoots(editor.selection);

        return selnRoots ? {
            docPath: outline.getPath(),
            ids: selnRoots.map(function (x) {
                return x.id;
            }),
            texts: selnRoots.map(function (x) {
                return x.bodyString;
            }),
            docLevels: outline.root.descendants
                .reduce(function (a, x) {
                    var intDepth = x.depth;
                    return intDepth > a ? intDepth : a;
                }, 0),
            selectionLevels: selnRoots.reduce(function (acc, item) {
                var strID = item.id,
                    intDeepest = item.hasChildren ? (
                        item.descendants
                        .reduce(function (a, x) {
                            var intDepth = x.depth;

                            return (intDepth > a ? intDepth : a);
                        }, 0) - (item.depth - (strID !== 'Birch' ? 1 : 0))
                    ) : 1;

                return (intDeepest > acc ? intDeepest : acc);
            }, 0)
        } : undefined;
    }

    // getNumberedCopy :: editor -> options -> String
    function getNumberedCopy(editor, options) {
        // indexSymbol :: Integer -> String -> String
        function indexSymbol(n, strStart) {
            if (isNaN(strStart)) {

                if (['i', 'I'].indexOf(strStart) !== -1) {
                    var strRoman = roman(n + 1);
                    return strStart === 'i' ? (
                        strRoman.toLowerCase()
                    ) : strRoman;
                } else {
                    var lstPoints = strStart.split('')
                        .map(function (c) {
                            return c.codePointAt(0);
                        });

                    return String.fromCodePoint.apply(
                        null,
                        init(lstPoints)
                        .concat(last(lstPoints) + n)
                    );
                }
            } else {
                return (n + parseInt(strStart, 10))
                    .toString();
            }
        }

        // roman :: Int -> String
        function roman(n) {
            return [
                    [1000, "M"],
                    [900, "CM"],
                    [500, "D"],
                    [400, "CD"],
                    [100, "C"],
                    [90, "XC"],
                    [50, "L"],
                    [40, "XL"],
                    [10, "X"],
                    [9, "IX"],
                    [5, "V"],
                    [4, "IV"],
                    [1, "I"]
                ]
                .reduce(function (a, lstPair) {
                    var m = a.remainder,
                        v = lstPair[0];

                    return (v > m ? a : {
                        remainder: m % v,
                        roman: a.roman + Array(
                                Math.floor(m / v) + 1
                            )
                            .join(lstPair[1])
                    });
                }, {
                    remainder: n,
                    roman: ''
                })
                .roman;
        }

        // Simple parse of model prefix string:
        // Possibly empty opening string
        // then lists of symbol plus affix (delimiter) pairs.

        // numberScheme :: String
        //      -> {start: String, [{symbol:String, delim:String}]}
        function numberScheme(strModel) {
            var puncts = strModel.split(/\w+/),
                strInit = puncts.length ? puncts[0] : undefined;

            return {
                start: strInit,
                levels: strModel.split(/\W+/)
                    .reduce(function (a, s, i) {
                        return (
                            s && a.push({
                                symbol: s,
                                affix: puncts[
                                    strInit ? i : i + 1
                                ]
                            }),
                            a
                        );
                    }, [])
            };
        }

        // Prefix string as function of a particular item's index path
        // and a parse of the model prefix string

        // numberPrefix :: [Int] ->
        //        {start:String, [{symbol:String, affix:String}]}
        //             -> String
        function numberPrefix(lstIndices, dctScheme, blnSkipLastDot) {
            var strPrefix = dctScheme.start + zipWith(
                    function (index, dct) {
                        return indexSymbol(
                            index, dct.symbol
                        ) + dct.affix;
                    },
                    lstIndices,
                    dctScheme.levels
                )
                .join('');

            return blnSkipLastDot && strPrefix.substr(-1) === '.' ? (
                strPrefix.slice(0, -1)
            ) : strPrefix;
        }

        // Tree of wrapped items enriched with indexPath property

        // withIndexPaths :: mItem -> mItem
        function withIndexPaths(mItem) {
            if (mItem.hasChildren) {

                mItem.children
                    .reduce(function (a, mChild) {
                        return (mChild.hasChildren ||
                            mChild.item.bodyContentString
                            .trim() !== '') ? (
                            mChild.indexPath = (
                                mItem.indexPath || []
                            )
                            .concat(a), // extending path with extra index
                            a + 1 // and incrementing only for text/parents
                        ) : a; // otherwise stet
                    }, 0);
            }

            return mItem;
        }

        // withSchemePrefixes :: mItem -> {start:, [{symbol:, affix:}]} -> mItem
        function withSchemePrefixes(mItem, dctScheme, blnNoFinalDot) {
            if (mItem.hasChildren) {
                mItem.children.forEach(function (mChild) {
                    if (mChild.hasChildren ||
                        mChild.item.bodyContentString
                        .trim() !== '') {
                        mChild.numPrefix = numberPrefix(
                            mChild.indexPath,
                            dctScheme,
                            blnNoFinalDot
                        );
                    }
                });
            }

            return mItem;
        }

        // Function f mapped over wrapped item and its
        // descendants

        // fmap :: (a -> b) -> f a -> f b
        function fmap(f, x) {
            var v = f(x);
            return (
                v.children = x.hasChildren ? x.children
                .map(function (c) {
                    return fmap(f, c);
                }) : [],
                v
            );
        }

        // Item and any descendants all wrapped
        // in an interface which can hold additional properties

        // a -> m a
        function mItemUnit(item) {
            var blnChiln = item.hasChildren;

            return {
                item: item,
                children: blnChiln ? (
                    item.children.map(mItemUnit)
                ) : [],
                hasChildren: blnChiln
            };
        }

        // (a -> b -> c) -> [a] -> [b] -> [c]
        function zipWith(f, xs, ys) {
            var ny = ys.length;

            return (xs.length <= ny ? xs : xs.slice(0, ny))
                .map(function (x, i) {
                    return f(x, ys[i]);
                });
        }

        // last :: [a] -> a
        function last(xs) {
            return xs.length ? xs.slice(-1)[0] : undefined;
        }

        // init :: [a] -> [a]
        function init(xs) {
            return xs.length ? xs.slice(0, -1) : undefined;
        }

        // numberedCopy :: mItem -> Bool -> String
        function numberedCopy(mItem, blnIndented) {
            var item = mItem.item;

            return (blnIndented ? (
                    Array(item.depth + 1)
                    .join('\t')
                ) : '') +
                (mItem.numPrefix || '') +
                (blnIndented ? '\t' : '\t\t') +
                (item.bodyContentString || '') +
                (item.getAttribute('data-type') === 'project' ? (
                    ':'
                ) : '') + '\n' +
                (mItem.hasChildren ? mItem.children
                    .map(function (mChild) {
                        return numberedCopy(mChild, blnIndented);
                    }) : [])
                .join('');
        }

        // selectionRoots :: editor -> maybe [Item]
        function selectionRoots(selection) {
            var lngSeln = selection.selectedItems.length,
                oStart = selection.startItem;

            return lngSeln === 1 ? (
                oStart.hasChildren ? [oStart] : [oStart.parent]
            ) : (lngSeln > 1 ? (
                selection.selectedItemsCommonAncestors
                .filter(function (x) {
                    return x.hasChildren || x.bodyString !== '';
                })
            ) : undefined);
        }

        // MAIN (getNumberedCopy)
        var dctScheme = numberScheme(options.outlineStyle),
            outline = editor.outline,
            root = outline.root,
            lstSelnRoots = selectionRoots(editor.selection),
            blnPruneLastDot = options.pruneLastDot;

        // Serialisation of a numbered wrapping of the outline (all / part)
        return numberedCopy(
            fmap(
                function (mItem) {
                    return withSchemePrefixes(
                        withIndexPaths(mItem),
                        dctScheme,
                        blnPruneLastDot
                    )
                },
                mItemUnit(
                    options.selectionOnly && lstSelnRoots.length ? (
                        lstSelnRoots[0]
                    ) : root
                )
            ),
            options.preserveIndents
        );
    }

    // JAVASCRIPT FOR AUTOMATION CONTEXT

    // PASHUA dialog functions
    // https://www.bluem.net/en/mac/pashua/

    // These functions are minimised adaptations from the bindings at
    // https://github.com/RobTrew/Pashua-Binding-JXA

    function showPashuaDialog(d, f) {
        // showPashuaDialog :: String | Object -> maybe String -> Object
        // Pashua dialog display ( See https://www.bluem.net/en/mac/pashua/ )
        var b = Application.currentApplication(),
            a = (b.includeStandardAdditions = !0, b),
            b = $.NSFileManager.defaultManager,
            g = "string" === typeof d ? d : asPashuaConfigString(d);
        if (f) {
            var e = a.pathTo("temporary items") + "/" +
                a.randomNumber().toString().substring(3),
                a = ($.NSString.alloc.initWithUTF8String(g)
                    .writeToFileAtomicallyEncodingError(
                        e, false, $.NSUTF8StringEncoding, null
                    ), a.doShellScript(
                        '"' + f + '/Contents/MacOS/Pashua" "' + e + '"'));
            b.removeItemAtPathError(ObjC.unwrap($(e)
                .stringByStandardizingPath), null);
            return a.split(/[\n\r]+/).reduce(function (a, b) {
                var c = b.trim();
                c && (c = c.split("="), 1 < c.length && (a[c[0]] = c
                    .slice(1).join("=")));
                return a;
            }, {});
        }
    }

    function asPashuaConfigString(a) {
        // asPashuaConfigString :: [{name:String, type:String, ... }] -> String
        // JS Object -> Pashua config string
        var f = /[\n\r]+/,
            g = /[\n\r]/gm;
        lstElements = a instanceof Array ? a : [a];
        return lstElements.reduce(function (a, b, h) {
            var e = b.name + ".";
            return b.name.length ? a + (h ? "\n\n" : "") + Object.keys(b)
                .reduce(function (a, c) {
                    var d = b[c];
                    "options" !== c ? -1 !== ["#", "comment", "comments"]
                        .indexOf(c.toLowerCase()) ?
                        a.push(d.split(f).map(function (a) {
                            return "# " + a;
                        }).join("\n")) : "name" !== c && a.push(e + c + " = " +
                            ("string" === typeof d ? d
                                .replace(g, "[return]") : d)) : a
                        .push(b.options.map(function (a) {
                            return e + "option = " + a;
                        }).join("\n"));
                    return a;
                }, []).join("\n") : a;
        }, "");
    }

    function maybePashuaPath(b) {
        // maybePashuaPath :: maybe String -> maybe String
        var a = Application.currentApplication(),
            c = (a.includeStandardAdditions = !0, a),
            e = $.NSFileManager.defaultManager,
            a = c.pathTo(this).toString();
        return [b && b.trim() || "", a + "/Contents/Resources/MacOS/",
            a.split("/").slice(0, -1).join("/")
        ].concat(["user", "system"].map(function (a) {
            return c.pathTo("applications folder", {
                from: a + " domain"
            }).toString();
        })).reduce(function (a, d) {
            if (a) return a;
            if (d) {
                var b = ("/" !== d.slice(-1) ? d + "/" : d) + "Pashua.app",
                    c = $();
                e.attributesOfItemAtPathError(ObjC.unwrap($(b)
                    .stringByStandardizingPath), c);
                if (undefined === c.code) return b;
            }
        }, undefined);
    }

    // JXA TaskPaper call

    // nreps :: Int -> String -> String
    function nreps(n, s) {
        var o = '';
        if (n < 1) return o;
        while (n > 1) {
            if (n & 1) o += s;
            n >>= 1;
            s += s;
        }
        return o + s;
    }

    var ds = Application('com.hogbaysoftware.TaskPaper3')
        .documents;

    if (ds.length) {

        // What is selected in the editor ?
        var d = ds[0],
            dctSeln = d.evaluate({
                script: getSelectionRoot.toString()
            });

        var strPashuaPath = maybePashuaPath(),
            strDocPath = ObjC.unwrap(
                $(dctSeln.docPath).stringByAbbreviatingWithTildeInPath
            ),
            intSelnLevels = dctSeln.selectionLevels,
            intDocLevels = dctSeln.docLevels,
            lstScopeOptions = dctDefaults.scopeOptions,
            lstIndentOptions = dctDefaults.indentOptions,
            iLast = (intDocLevels * 2) - 1,
            strDefault = dctDefaults.outlineStyle.slice(0, iLast),

            lstUIParts = [{
                "Comments": "Set window title",
                "name": "*",
                "title": "Copy as numbered outline"
            }, {
                "Comments": "File path display",
                "name": "fp",
                "type": "text",
                "default": (strDocPath || 'Untitled'),
                "tooltip": "Sequence of symbols and delimiters"
            }, {
                "Comments": "Section selected",
                "name": "sln",
                "type": "text",
                "label": "(Selected sections – descendants not shown)",
                "default": (dctSeln.texts.length ? dctSeln.texts[0] : ''),
                "tooltip": "Top level of selection"
            }, {
                "Comments": "Combo for numbering pattern",
                "name": "outlineStyle",
                "type": "combobox",
                "label": "Outline numbering pattern:",
                "default": strDefault,
                "options": [
                    strDefault,
                    '0.' + nreps(intDocLevels - 1, '1.').slice(0, -1),
                    'A I 1 i'.slice(0, iLast),
                    'A 1.1.1'.slice(0, iLast),
                ],
                "tooltip": "Sequence of symbols and delimiters"
            }, {
                "Comments": "Trim final dot",
                "name": "pruneLastDot",
                "type": "checkbox",
                "default": dctDefaults.pruneLastDot ? 1 : 0,
                "label": "Trailling dots trimmed off",
                "tooltip": "Remove any trailling dots from numbering"
            }, {
                "Comments": "Indents preserved or removed",
                "name": "preserveIndents",
                "type": "radiobutton",
                "default": lstIndentOptions[
                    dctDefaults.preserveIndents ? 0 : 1
                ],
                "label": "Indents:",
                "options": lstIndentOptions,
                "tooltip": "Preserve outline indents, or left-align"
            }, {
                "Comments": "Default button - Copy",
                "name": "db",
                "type": "defaultbutton",
                "label": "Copy",
                "tooltip": "Copy as numbered outline"
            }, {
                "Comments": "Cancel button",
                "name": "cancel",
                "type": "cancelbutton",
                "label": "Cancel",
                "tooltip": "Close this dialog"
            }, {
                "Comments": "Selection or whole document",
                "name": "selectionOnly",
                "type": "radiobutton",
                "label": "Copy:",
                "options": lstScopeOptions,
                "default": lstScopeOptions[
                    dctDefaults.selectionOnly ? 1 : 0
                ],
                "tooltip": "Whole doc or selection & descendants"
            }],

            dctChoice = (
                dctDefaults.useDialogIfPashuaFound && strPashuaPath
            ) ? (
                showPashuaDialog(
                    lstUIParts,
                    strPashuaPath
                )
            ) : dctDefaults;


        // What is the user response to the dialog ?
        //return JSON.stringify(dctChoice);

        // What does a numbered version look like ?
        var strClip = (dctChoice.cancel !== "1" ? d.evaluate({
            script: getNumberedCopy.toString(),
            withOptions: (
                dctChoice.selectionOnly = (
                    dctChoice.selectionOnly === true ||
                    dctChoice.selectionOnly === dctDefaults.scopeOptions[1]
                ),
                dctChoice.preserveIndents = (
                    dctChoice.preserveIndents === true ||
                    dctChoice.preserveIndents === dctDefaults.indentOptions[0]
                ),
                dctChoice
            )
        }) : undefined);

        if (strClip) {
            var a = Application.currentApplication(),
                sa = (a.includeStandardAdditions = true, a),
                maybeFile = Application('TaskPaper').documents[0].file();

            sa.setTheClipboardTo(strClip);

            sa.displayNotification(
                strClip, {
                    withTitle: 'Numbered copy',
                    subtitle: 'From: ' + (maybeFile ? (
                        ObjC.unwrap(
                            $(maybeFile.toString())
                            .stringByAbbreviatingWithTildeInPath
                        )
                    ) : 'Untitled'),
                    soundName: 'default'
                }
            )
            return strClip;
        }
    }
})({
    outlineStyle: '1.1.1.1.1.1.1.1.1',
    pruneLastDot: true,
    preserveIndents: true,
    selectionOnly: false,

    scopeOptions: [
        "Whole document",
        "Selection & descendants"
    ],
    indentOptions: [
        "Preserve indents",
        "Align numbered paras to left"
    ],

    // https://www.bluem.net/en/mac/pashua/
    useDialogIfPashuaFound: true
});

2 Likes

Wow. Thank you very much.

2 Likes