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.

Hi, this is extremely useful, thanks! Quick question: how can I modify the script if I want it to not return the string (invalid date) if I use other texts in parentheses?

Thanks, Hans

As always, I think you'll need to show a couple of concrete examples.

(Inputs, paired with expected outputs)

Ooops... I answered a week ago but it didn't show up here. Sorry for the delay:


thanks for the response!

  1. This is what we have:

@due(today) --> @due(2022-01-17)
@due(2 weeks) --> @due(2022-01-31 12:33:51:112)

These would return (invalid date), but I want them to stay as is:
...we can do days (or weeks) --> [stays at] ...we can do days (or weeks)
...the Byzantine Empire (395-1453) --> [stays at] ...the Byzantine Empire (395-1453)
...such as Nam June Paik's early works (e.g. Confused Rain) --> [stays at] ...(e.g. Confused Rain)

These tests also revealed that every single year in parentheses resolves to Jan 1, such as
Alison Knowles' House of Dust (1967) --> ...House of Dust (1967-01-01).
I do have years in parentheses all over the place.

  1. I can see the following solutions to this:
  • instead of returning (invalid date), it returns nothing and leaves the characters as is. I could not figure out how to change the code to do that.
  • or it is possible to have the conversion only run on tags, i.e. after the @-sign?
  • I realize the problem also occurs every time I press the closing parenthesis after an opening parenthesis, followed by another character. If the above mentioned solutions are complicated, my workaround would be to use a different hotkey to trigger the conversion.

Thanks, Hans

Here's a later variant which:

  • Is triggered differently: it expects =) to be typed at the end of a string (the = gets deleted)
  • ignores strings that don't parse as dates: just leaves them in place:

Convert parenthesised informal date to ISO (draft alternative-).kmmacros (12 KB)

1 Like

Works perfectly, thanks! Time to learn some more Javascript. :wink:

Hans

1 Like

If you're happy with that solution, then you don't need to read my alternate solution.

There's a way to convert some "natural language time expressions" to "standard date expressions" using the "at" command in Unix, which MacOS supports (but it currently deprecated.) You don't need Javascript for this at all.

For example, the standard Unix command "at midnight next month" will let you enter a command to be executed at the first midnight next month. The result of the command will be a standard date format string. Here's a table I found online that lists SOME (but not all) of the natural language dates you can specify: (yay! I managed to insert my first table successfully on this website)

Time Expression Time of Command Execution
noon 12:00 PM November 18 2020
midnight 12:00 AM November 19 2020
tomorrow 8:00 AM November 19 2020
noon tomorrow 12:00 PM November 19 2020
next week 8:00 AM November 25 2020
next Monday 8:00 AM November 23 2020
fri 8:00 AM November 20 2020
6:00 AM 6:00 AM November 19 2020
2:30 PM 2:30 PM November 18 2020
1430 2:30 PM November 18 2020
2:30 PM tomorrow 2:30 PM November 19 2020
2:30 PM next month 2:30 PM December 18 2020
2:30 PM 11/21 2:30 PM November 21 2020
2:30 PM Nov 21 2:30 PM November 21 2020
2:30 PM 11/21/2020 2:30 PM November 21 2020
now + 30 minutes 8:30 AM November 18 2020
now + 1 hour 9:00 AM November 18 2020
now + 2 days 8:00 AM November 20 2020
4 PM + 2 days 4:00 PM November 20 2020
now + 3 weeks 8:00 AM December 8 2020
now + 4 months 8:00 AM March 18 2021

Here's an actual example of the command in action. Right now the time is about noon on my Mac.

~/ at teatime + 30 minutes 
say nothing        
job 22 at Sat Feb 19 16:30:00 2022

Notice how it produced an actual date-time string from the words "teatime + 30 minutes".

If you don't need this sort of feature, feel free to ignore this post.

1 Like

This is good to know, thank you! That "teatime" can be interpreted is actually funny. ;-)))

Which OS do you use for the Unix command? On 11.6 Big Sur the "at" command isn't found.

Thanks, Hans

I use MacOS Monterey. The "at" command exists, but some kind of security permission seems to have changed making the command appear to function, but it doesn't actually run the script that you schedule. For this purpose, that's a harmless feature, because we're only running "at" to convert the time.

I'm very surprised that you say "at" doesn't exist in Big Sur. That would mean Apple removed it and put it back in after it was deprecated. Instead, I would speculate that your path may have been modified making "at" unavailable. Or maybe "at" is a built-in shell command and you are using the wrong shell.

Yes yes, I realize it looks for zsh, and says "command not found"!

Hans

I found the at command in here, by entering this command in Terminal:

~/ whereis at
/usr/bin/at