Regex - Variable Spacing Between 2 SubStrings to Produce Uniform Two Column Table

I can't figure out how to place the correct number of "." between the "title" and the time stamp.
Can anyone point me where to look?

    Requirements for Diagrammatic Knowledge Mapping Techniques [[201901110943]]  
    Limited working memory [[201901110947]]
    Knowledge visualization [[201901110957]]
    Personal Knowledge Management recipient [[201901111005]]
    Purpose of Knowledge Management [[201901111008]]
    Linking to prior knowledge [[201901111020]]
    Niklas Luhmann’s Zettelkasten [[201901111024]]

Requirements for Diagrammatic Knowledge Mapping Techniques ...[[201901110943]]  
Limited working memory .......................................[[201901110947]]
Knowledge visualization ......................................[[201901110957]]
Personal Knowledge Management recipient ......................[[201901111005]]
Purpose of Knowledge Management ..............................[[201901111008]]
Linking to prior knowledge ...................................[[201901111020]]
Niklas Luhmann’s Zettelkasten ................................[[201901111024]]

One approach (in an Execute JS action) is to use a justifyLeft function on the left column:

Dotted gap.kmmacros (21.0 KB)


JS source

(() => {
    'use strict';

    const main = () => {
            kme = Application('Keyboard Maestro Engine'),
            s = kme.getvariable('noteLines');

            xys = (
                map(x => splitOn(' [[', strip(x)),
            gap = 3,
            w = length(fst(
                    comparing(compose(length, fst)),
                ))) + gap;

        return unlines(
                ([a, b]) => justifyLeft(w, '.', a) + '[[' + b,

    // GENERIC FUNCTIONS ----------------------------

    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        (x, y) => {
                a = f(x),
                b = f(y);
            return a < b ? -1 : (a > b ? 1 : 0);

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (f, g) => x => f(g(x));

    // fst :: (a, b) -> a
    const fst = tpl => tpl[0];

    // justifyLeft :: Int -> Char -> String -> String
    const justifyLeft = (n, cFiller, s) =>
        n > s.length ? (
            s.padEnd(n, cFiller)
        ) : s;

    // Returns Infinity over objects without finite length.
    // This enables zip and zipWith to choose the shorter
    // argument when one is non-finite, like cycle, repeat etc

    // length :: [a] -> Int
    const length = xs =>
        (Array.isArray(xs) || 'string' === typeof xs) ? (
        ) : Infinity;

    // lines :: String -> [String]
    const lines = s => s.split(/[\r\n]/);

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) =>
        (Array.isArray(xs) ? (
        ) : xs.split('')).map(f);

    // maximumBy :: (a -> a -> Ordering) -> [a] -> a
    const maximumBy = (f, xs) =>
        0 < xs.length ? (
            .reduce((a, x) => 0 < f(x, a) ? x : a, xs[0])
        ) : undefined;

    // splitOn :: [a] -> [a] -> [[a]]
    // splitOn :: String -> String -> [String]
    const splitOn = (pat, src) =>

    // strip :: String -> String
    const strip = s => s.trim();

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // MAIN ---
    return main();

1 Like

Or equivalently, in an Execute an AppleScript action:

Dotted gap (AS).kmmacros (25.8 KB)

on run
    tell application "Keyboard Maestro Engine" to set s to getvariable ("noteLines")
    script cols
        on |λ|(x)
            splitOn(" [[", x)
        end |λ|
    end script
    set xys to map(cols, |lines|(s))
    set gap to 3
    set w to gap + (length of ¬
        fst(maximumBy(comparing(compose(|length|, fst)), xys)))
    script dotted
        on |λ|(ab)
            set {a, b} to ab
            justifyLeft(w, ".", a) & "[[" & b
        end |λ|
    end script
    unlines(map(dotted, xys))
end run

-- GENERIC -----------------------------------------------

-- comparing :: (a -> b) -> (a -> a -> Ordering)
on comparing(f)
        on |λ|(a, b)
            tell mReturn(f)
                set fa to |λ|(a)
                set fb to |λ|(b)
                if fa < fb then
                else if fa > fb then
                end if
            end tell
        end |λ|
    end script
end comparing

-- compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
on compose(f, g)
        property mf : mReturn(f)
        property mg : mReturn(g)
        on |λ|(x)
            mf's |λ|(mg's |λ|(x))
        end |λ|
    end script
end compose

-- foldl :: (a -> b -> a) -> a -> [b] -> a
on foldl(f, startValue, xs)
    tell mReturn(f)
        set v to startValue
        set lng to length of xs
        repeat with i from 1 to lng
            set v to |λ|(v, item i of xs, i, xs)
        end repeat
        return v
    end tell
end foldl

-- fst :: (a, b) -> a
on fst(tpl)
    if class of tpl is record then
        |1| of tpl
        item 1 of tpl
    end if
end fst

-- justifyLeft :: Int -> Char -> String -> String
on justifyLeft(n, cFiller, strText)
    if n > length of strText then
        text 1 thru n of (strText & replicate(n, cFiller))
    end if
end justifyLeft

-- length :: [a] -> Int
on |length|(xs)
    set c to class of xs
    if list is c or string is c then
        length of xs
        (2 ^ 29 - 1) -- (maxInt - simple proxy for non-finite)
    end if
end |length|

-- lines :: String -> [String]
on |lines|(xs)
    paragraphs of xs
end |lines|

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    tell mReturn(f)
        set lng to length of xs
        set lst to {}
        repeat with i from 1 to lng
            set end of lst to |λ|(item i of xs, i, xs)
        end repeat
        return lst
    end tell
end map

-- maximumBy :: (a -> a -> Ordering) -> [a] -> a
on maximumBy(f, xs)
    set cmp to mReturn(f)
    script max
        on |λ|(a, b)
            if a is missing value or cmp's |λ|(a, b) < 0 then
            end if
        end |λ|
    end script
    foldl(max, missing value, xs)
end maximumBy

-- Lift 2nd class handler function into 1st class script wrapper 
-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
    if class of f is script then
            property |λ| : f
        end script
    end if
end mReturn

-- Egyptian multiplication - progressively doubling a list, appending
-- stages of doubling to an accumulator where needed for binary 
-- assembly of a target length
-- replicate :: Int -> a -> [a]
on replicate(n, a)
    set out to {}
    if n < 1 then return out
    set dbl to {a}
    repeat while (n > 1)
        if (n mod 2) > 0 then set out to out & dbl
        set n to (n div 2)
        set dbl to (dbl & dbl)
    end repeat
    return out & dbl
end replicate

-- splitOn :: String -> String -> [String]
on splitOn(pat, src)
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, pat}
    set xs to text items of src
    set my text item delimiters to dlm
    return xs
end splitOn

-- unlines :: [String] -> String
on unlines(xs)
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, linefeed}
    set str to xs as text
    set my text item delimiters to dlm
end unlines
1 Like

and of course, with an Execute a Shell Script action, you can use any other scripting language from KM.

Where Python is preferred, for example (assuming an installation of Python3 at /usr/local/bin/python3)

Dotted gap (PY).kmmacros (28.2 KB)

Shell script source for running Python

/usr/local/bin/python3 <<PY_END 2>/dev/null
from functools import (reduce)
import re

def main():
    s = '''$KMVAR_noteLines'''

    xys = map_(compose(splitOn(' \[\['))(strip))(
    gap = 3
    w = gap + length(fst(
    print (
            map_(lambda ab: justifyLeft(w)('.')(fst(ab)) + '[[' + snd(ab))(

# GENERIC -------------------------------------------------

# compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
def compose(g):
    return lambda f: lambda x: g(f(x))

# fst :: (a, b) -> a
def fst(tpl):
    return tpl[0]

# justifyLeft :: Int -> Char -> String -> String
def justifyLeft(n):
    return lambda cFiller: lambda a: (
        (str(a) + (n * cFiller))[:n]

# length :: [a] -> Int
def length(xs):
    return len(xs)

# lines :: String -> [String]
def lines(s):
    return s.splitlines()

# map :: (a -> b) -> [a] -> [b]
def map_(f):
    return lambda xs: list(map(f, xs))

# maximumBy :: (a -> a -> a) -> [a] -> a
def maximumBy(f):
    def go(x, y):
        return y if f(y) > f(x) else x
    return lambda xs: reduce(go, xs[1:], xs[0])

# snd :: (a, b) -> b
def snd(tpl):
    return tpl[1]

# splitOn :: [a] -> [a] -> [[a]]
# splitOn :: String -> String -> [String]
def splitOn(pat):
    return lambda s: re.split(pat, s)

# strip :: String -> String
def strip(s):
    return s.strip()

# unlines :: [String] -> String
def unlines(xs):
    return '\n'.join(xs)

# MAIN ---
if __name__ == '__main__':

1 Like

This is very interesting. I only problem I see so far is that the "gap = 3" for the first of the list may not be known in advance. Sure in the sample I gave it was but in another case the first in the list would be shorter than the following and require a different number of "." It would be nice to use a set right justification margin. All such actions would have the same look. My page is 80 characters wide and the time stamp with the [[ ]] is always 16 characters. But the titles vary in length unpredictably. This macro works great for this sample but is not reusable yet.

Thanks so much.

With more testing and use, this seems to work differently than I first thought. The script makes adjustments to the length based on the longest title/time-stamp comb and 3 "."'s there and then adjusting the other lines to match. The overall length of the group of lines would be variable between groups. This is both an advantage and a disadvantage. Advantage because it works and looks good. A disadvantage is when adding to the list and the macro creates a different number of "."'s for the addition as opposed to the first time the macro is run.

This is working pretty good. I see how this was done and am thankful for the help. Now just small tweaks because of my unclarity around all the use cases.

Hey Will,

This sort of entabling can get pretty complicated, but here's a relatively simple Perl script that does what you want.


Create Table with Dot Leaders.kmmacros (5.5 KB)

1 Like

Hey Will,

It's beneficial to mention those sorts of specifications when first describing your needed workflow. It saves everyone time and effort (including yourself).

Here's a version that allows you to set the maximum table width. I've preset it to 80 characters.

I have NOT added error-checking to make certain your table line lengths do not already exceed that limit.


Create Table with Dot Leaders -- Adjustable Width.kmmacros (5.4 KB)

1 Like

Just for fun here's a tersification.  :sunglasses:

#!/usr/bin/env perl -sw
use v5.010;
use utf8;
binmode(STDIN, ":utf8");
binmode(STDOUT, ":utf8"); 

while (<>) {s!\A\s+|\s+\Z!!g;s!(\h+)(?=\[)!$1.('.' x (80 - length($_)))!ge;say;}
1 Like

As you have now found, it is in fact reusable and general :slight_smile:

The part of the code to look at is the application of the maximumBy function which returns the widest line of text.

The width of the left column is defined as that of the widest line plus a standard amount of padding.
The gap constant in that draft is just based on the observation that you were padding the widest line in that case with an additional three dots – you can set any value that you prefer as a minimum, to prevent text from touching the opening [[, or derive it from a global maximum like 80 chars.

Of course if your global maximum is hard, then you may end up truncating the text sometimes ...

(and with a few more lines of code you could wrap it)

1 Like

You guys really just reach for a script solution for everything? This is actually easy to do with two actions and no scripting. Search and replace (?m)^(.{1,70})(\[\[\d+\]\])$ for $1.$2. This adds one dot if the line length is less than about 80 (assuming the date stamp is always a consistent size). Adjust the 70 as required. If the line length is longer, it does nothing. Then just repeat this 80 times. Job done.

Keyboard Maestro Actions.kmactions (1.6 KB)


Good point, Peter. Us scripters often reach for the tool we know/like best. Everyone should know that, while Keyboard Maestro (KM) works very well with scripts, one of the main purposes of KM is to provide automated solutions that do NOT require a script,, making it a great tool for everyone! :smile:

And I am the first to say: any solution is a good solution.

Indeed, the scripting on this forum regularly blows me away — I just posted on TidBITS talk about the awesome scripters on this forum and the amazing things you guys do.

I do occasionally worry a novice visitor to the forum will see all these amazing scripts and be scared off, when really they should see all these amazing scripts and generous folks and just ask for help with anything they need and revel in the plethora and variety of answers they get.

Hey Peter,

Ha! I'll take that challenge...

Regex Test -- Create 80 Character Wide Table with Dot Leaders.kmmacros (7.7 KB)

Have you actually looked at your own solution?

A novice visitor would be cross-eyed!  :sunglasses:


Heh. The regex is, as usual, opaque. But then pretty much all regex is opaque, it, like perl, is very much a write-only language.

Hey guys there is room for everyone. As a beginner I was intimidated by this whole project. This is a new language for me. Scripting rather JavaScript, python, perl, or Regex is at first confusing. But personally I relish the challenge. In the end, I looked at and tried to assimilate everyone's contribution and ccstone's Perl script won. Not sure why I chose his, they all work.
You have all been so helpful to this novice.

1 Like

Actually, I think RegEx is much easier to use in KM than in any script, especially AppleScript which requires complex ASObjC to use (or a Scripting Addition).

I suspect that a lot more people understand RegEx than AppleScript, since RegEx is used on all platforms, and has been in use for decades.

KM has two primary RegEx Actions, and they are well documented here:
Regular Expressions (KM Wiki)

Fortunately, we have some great tools like to help explain a RegEx expression. So your RegEx is documented here:


Having said all that, I will be the first to say that RegEx is NOT trivial to learn, although you can get started and be productive with some simple RegEx patterns.
For more info, see Regular Expressions Quick Start .

1 Like

Hey Folks,

Here's another RegEx-centric solution that takes a different tack than my macro in post #14.

Fighting with that makes me want to run screaming back to my 1-liner Perl solution...  :sunglasses:


Create Table with Dot Leaders -- Keyboard Maestro Loop and RegEx v1.00.kmmacros (17 KB)

Hey Folks,

Here's a pretty spare AppleScript version using Keyboard Maestro for RegEx find/replace support.


Create Table with Dot Leaders -- Adjustable Width -- AppleScript v1.01.kmmacros (9.4 KB)