Copy from TaskPaper 3 as numbered outline

If Pashua.app is installed, this macro will display a dialog.

If not, the options can be adjusted at the end of the script.

(The same could certainly be achieved with a KM HTML dialog – I just happen to be a little more familiar with Pashua.app dialogs)

The JavaScript for Automation binding functions for Pashua used here are adapted from:


Copy from TaskPaper 3 as numbered outline.kmmacros (40.2 KB)

JavaScript source:

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

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

// 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, !0, $.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": "cb",
                "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 = 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
            )
        });

        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