Convert informal date-times to yyyy-mm-dd [HH:MM] in TaskPaper 3 Preview

TaskPaper 3 queries can use informal date-time expressions,

http://guide.taskpaper.com/formatting_dates.html

but the line tags need to use a simple ISO 8601 date-time format

Here is a macro which converts informal expressions when you type a closing parenthesis after them in TaskPaper 3 Preview

e.g.

@due(tomorrow)      ->    @due(2016-02-13)
@alert(may 24 2pm)  ->    @alert(2016-05-24 14:00)
@due(14 days)       ->    @due(2016-02-26 09:55)

etc.

( Untranslatable expressions between the newly closed parentheses are ignored )

Convert parenthesised informal date to ISO.kmmacros (22.5 KB)

Source of JS action

(function () {
    'use strict'

    // ver 0.11 update for TaskPaper 3 Preview build 170
    //          uses new api:
    //              - DateTime.format()
    //              - outline.groupUndoAndChanges()

    // CONVERTING INFORMAL DATES IN TAGS TO TASKPAPER ISO

    // Replaces any date-translatable text either:
    // 1. in the currently extended selection, or
    // 2. between the first pair of parentheses which
    //    contains or precedes the cursor


    function fnTP3(editor, options) {

        // (editor state) -> maybeReplacement
        // () -> {phraseFound: Boolean, isValid:Boolean, line:Item, 
        //          iso:String, from:Integer, to:Integer} 
        function maybeDateEdit(editor) {
            // current line
            var oSeln = editor.selection || undefined;

            if (oSeln) {
                // Extended selection ?
                var oLine = oSeln.startItem,
                    strText = oLine.bodyString,
                    iStart = oSeln.startOffset,
                    iEnd = oSeln.endOffset,
                    lngSeln = iEnd - iStart,
                    strSeln = lngSeln ? strText.slice(iStart, iEnd) : '';


                // or @key(value) tag starting before selection ?
                if (lngSeln < 1) {
                    var brkFrom = strText.slice(0, iStart)
                        .lastIndexOf('('),
                        brkTo = brkFrom !== -1 ? brkFrom + strText.slice(
                            brkFrom
                        )
                        .indexOf(')') : -1,
                        strParen = (
                            (brkFrom !== -1) && (brkTo !== -1)
                        ) ? strText.slice(brkFrom + 1, brkTo) : '';
                }

                // Extended selection gets priority if both
                var strPhrase = strSeln || strParen,
                    blnText = strPhrase.length > 0,
                    strISO = blnText ? DateTime.format(strPhrase.trim()) :
                    '';

                return {
                    phraseFound: blnText,
                    from: blnText ? (lngSeln ? iStart : brkFrom + 1) : undefined,
                    to: blnText ? (lngSeln ? iEnd : brkTo) : undefined,
                    iso: strISO,
                    isValid: (strISO.indexOf('Invalid') === -1),
                    line: blnText ? oLine : undefined
                };
            } else return {};
        }

        // MAIN
        var dctEdit = maybeDateEdit(editor);

        if (dctEdit.phraseFound) {
            if (dctEdit.isValid) {
                var oLine = dctEdit.line,
                    iFrom = dctEdit.from;

                editor.outline.groupUndoAndChanges(function () {
                    oLine.replaceBodyRange(
                        iFrom,
                        dctEdit.to - iFrom,
                        dctEdit.iso
                    );
                });

                return oLine.bodyString; // updated string
            } else return dctEdit.iso; // or error message
        } else return undefined // or nothing

    }

    var tp = Application("TaskPaper"),
        ds = tp.documents,
        strMaybeEdit = ds.length ? ds[0].evaluate({
            script: fnTP3.toString(),
            withOptions: {}
        }) : '';

    tp.activate();

    return strMaybeEdit;
})();
6 Likes

This is awesome Rob!

1 Like

Is there a way to edit this script so that the dates render without the hours and minutes in TaskPaper? Thanks!

Could you give me a couple of examples of the input strings you have in mind ?

(2 or 3 (from -> to) examples ?)

I'm finding here, for example that with the closing parenthesis,

@due(tomorrow)

converts to

@due(2020-11-28)

while (dec 10) converts to (2020-12-10)

both without a trailing time-string,

but perhaps the typical input string that you have in mind is slightly different ?

Yes, thank you!

@due(1 week) and @due(3 days) seem to be two examples where time is rendered.

For background, the reason I am asking is that when the time is rendered in the tags my @due list in the sidebar generates multiple listings for the same date when since the hours differ.

Appreciate the help!

I see – yes, TaskPaper's built-in date parser is taking the view that 'in one day' written last thing at night will often need to be distinguished from 'in one day' written first thing in the morning.

You could write a general truncation of times, but then the time would also be truncated when you wrote things like:

@meeting(tomorrow 2pm)

for which the DateTime.format method yields

@meeting(2020-11-28 14:00)

but a truncating version of the script would throw away the time and reduce it down to:

@meeting(2020-11-28)

Thanks for the insight...I have discovered that @date(next friday) yields @due(2020-12-04) so I will just use that syntax for a week later or the @date(dec 4) format for a specific date

1 Like

You could test this variant to see if it matches what you are after – it experimentally trims the time component off interval expressions if the unit is a day or larger, but leaves it in place where N hours or N minutes are given.

Convert parenthesised informal date to ISO (draft alternative,).kmmacros (31.2 KB)

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

    // Rob Trew @2020
    // Draft 0.02

    // -- Rob Trew (c) 2020
    // --
    // -- 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.

    // main :: IO ()
    const main = () => {
        const inner = () => {
            const
                ds = Application('TaskPaper')
                .documents;
            return either(
                alert('Problem')
            )(
                x => x
            )(
                bindLR(
                    0 < ds.length ? (
                        Right(ds.at(0))
                    ) : Left('No TaskPaper documents open')
                )(
                    d => d.evaluate({
                        script: tp3Context.toString(),
                        withOptions: {
                            tagName: 'now'
                        }
                    })
                )
            );
        };

        // -------------- TASKPAPER CONTEXT --------------

        const tp3Context = (editor, options) => {
            const
                rgxInterval = (
                    /^\s*\d+\s*(d|w|week|month|y|year)/
                );
            const main = () => {
                const lrExtended = extendedSelectionLR(
                    editor
                );
                return bindLR(
                    Boolean(lrExtended.Left) ? (
                        selectedTagValueAndPosnLR(editor)
                    ) : lrExtended
                )(
                    ([phrase, from]) => {
                        const parse = DateTime.format(phrase);
                        return bindLR(
                            'invalid date' !== parse ? (
                                Right(
                                    rgxInterval.test(phrase) ? (
                                        parse.slice(0, 10)
                                    ) : parse
                                )
                            ) : Left(`${parse} :: ${phrase}`)
                        )(
                            isoString => (
                                // ----- EDITOR TEXT -----
                                editor.outline
                                .groupUndoAndChanges(() =>
                                    editor.selection.startItem
                                    .replaceBodyRange(
                                        from,
                                        phrase.length,
                                        isoString
                                    )
                                ),
                                // ---- OUTPUT STREAM ----
                                Right(`${phrase} -> ${isoString}`)
                            )
                        );
                    }
                );
            };

            // selectedTagValueAndPosnLR :: Editor -> 
            // Either String (String, Int, Int)
            const selectedTagValueAndPosnLR = editor => {
                // Either a message or some parenthesised text.
                // From preceding opening parenthesis if any
                // to next closing parenthesis on same line, 
                // if any.
                const
                    seln = editor.selection,
                    txt = seln.startItem.bodyString,
                    iPosn = seln.startOffset,
                    iClose = [...txt.slice(iPosn)]
                    .findIndex(
                        c => ')' === c
                    ),
                    iOpen = [...txt.slice(0, iPosn)]
                    .reverse().findIndex(
                        c => '(' === c
                    ),
                    value = dropWhileEnd(
                        c => c === ')'
                    )(txt);
                return Right(
                    -1 === iOpen ? [
                        value, 0
                    ] : [
                        value.slice(
                            iPosn - iOpen,
                            -1 !== iClose ? (
                                iPosn + iClose
                            ) : undefined
                        ),
                        iPosn - iOpen
                    ]
                );
            };


            // extendedSelectionLR :: Editor -> 
            // Either String String
            const extendedSelectionLR = editor => {
                // Either a message or the text selected
                // in the first selected item.
                const
                    seln = editor.selection,
                    start = seln.startOffset;
                return seln.isCollapsed ? (
                    Left('Selection not extended')
                ) : Right([
                    seln.startItem.bodyString
                    .slice(
                        start,
                        seln.endOffset
                    ),
                    start
                ]);
            };

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

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

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


            // dropWhileEnd :: (a -> Bool) -> [a] -> [a]
            // dropWhileEnd :: (Char -> Bool) -> String -> [Char]
            const dropWhileEnd = p =>
                // xs without the longest suffix for which
                // p returns true for all elements.
                xs => {
                    let i = xs.length;
                    while (i-- && p(xs[i])) {}
                    return xs.slice(0, i + 1);
                };

            // findIndex :: (a -> Bool) -> [a] -> Maybe Int
            const findIndex = p =>
                //  Just the index of the first element in
                //  xs for which p(x) is true, or
                //  Nothing if there is no such element.
                xs => {
                    const i = [...xs].findIndex(p);
                    return -1 !== i ? (
                        Just(i)
                    ) : Nothing();
                };

            return main();
        };


        // 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',
                    withIcon: sa.pathToResource('TaskPaper.icns', {
                        inBundle: 'Applications/TaskPaper.app'
                    })
                }),
                s
            );
        };

        return inner()
    };

    // ----------------- LIBRARY IMPORT ------------------

    // Evaluate a function f :: (() -> a)
    // in the context of the JS libraries whose source
    // filePaths are listed in fps :: [FilePath]

    // Evaluate a function f :: (() -> a)
    // in the context of the JS libraries whose source
    // filePaths are listed in fps :: [FilePath]
    // usingLibs :: [FilePath] -> (() -> a) -> a
    const usingLibs = (fps, f) => {
        const gaps = fps.filter(fp => !doesFileExist(fp));
        return 1 > gaps.length ? eval(
            `(() => {
                'use strict';
                ${fps.map(readFile).join('\n\n')}
                return (${f})();
             })();`
        ) : 'Library not found at: ' + gaps.join('\n');
    };

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

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

    // -------- GENERIC FUNCTIONS FOR JXA CONTEXT --------

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

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

    // 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 => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;

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

Thank you...I tried testing but the script does not seem to be doing anything. I'm new to scripting and KM, assuming that the original macro needs to be disabled and this new one enabled. According to the KM troubleshooter, the new macro is enabled and firing, just not showing any change to the input text in TP.

Mmm ... trying to think what the differences might be between our systems ...

Here macOS 10.15.7, Paddle version of TP3 (3.8.12)

and as you say, the original version of the KM would need to be disabled.