af2cal
Fabrice P. Lauss𝕪s ygentoman Web

af2cal

af2cal puts an Anno Fabri tag into Google Calendar: af2cal 0Boom coffee at the Café de la Luz and the meeting is there, at the second the tag stands for, carrying the tag itself in its description. It keeps the company of dates2wiki, mdtable2wiki and TeX2unicode.

Written with Claude Opus 5 on 22 August (2026). Version 1.5.0.

The idea

It is convenient to consign a meeting with an AF tag, such as 02HUG (af), have the people involved notified and to let the computer do all the bookmarking of this in the calendar.

This can be achieved minimally with Google's own action=TEMPLATE address, which opens the new event form with every field already filled in: just press Save and it is done. Nothing is installed, nothing is authorised, no token is stored, and nothing at all reaches Google unless you press that button. Closing the tab is a perfect undo.

laussy@azag:~$ af2cal 02HUG "Caixa forum Asurbanipal" @daniele
02HUG — Sunday 23 August 2026, 14:51:06 CEST
Caixa forum Asurbanipal
with [email protected]

Usage

af2cal 02HUG Caixa forum Asurbanipal     # one hour, from the second the tag names
af2cal 02HUG                             # a bare tag is its own title
af2cal 02HUG lunch @da                   # ...and invite him
af2cal 02HUG group meeting @psav         # ...or the five of them
af2cal -d 90m -l "the blue room" 07fun group meeting
af2cal -e 0Boom 02HUG a long afternoon   # ends at a second tag
af2cal -a 12345 holiday                  # all day
af2cal -u 12345                          # print the address, open nothing
Option What it does
-d, --duration 45, 90m, 1h, 1h30, 2d. One hour by default
-e, --end end at a second AF tag instead of a duration
-a, --all-day an all-day entry on the tag's date
-l, --location where
-m, --memo extra lines in the description, under the tag
-w, --with guests: addresses and @shortcuts, comma-separated
-u, --url print the address instead of opening it
-n, --dry-run the decoded time and the address, and nothing else happens
-c, --contacts [PAT] list the address book; PAT keeps the lines matching it, name or address
--names the shortcut names alone, resolving nothing
--match @a the shortcuts beginning that way, each with what it stands for (what the completion asks)
-x, --expand resolve @shortcuts to addresses and print them
--add NAME WHO put a shortcut in the book—or add the address to a name already there
--set NAME WHO the same, replacing what the name stood for
--rm NAME take a shortcut out
--import FILE read a Google Contacts export, .csv or .vcf

Guests

An address written anywhere among the words is a guest and not a word: af2cal 02HUG lunch [email protected] at the market makes a lunch at the market and puts him on it. Google asks whether to send the invitations when the event is saved, so the mail goes out on the same single click as the event.

Nobody types an address twice, so ~/.af2cal-contacts keeps them, one shortcut a line:

da           = [email protected]
gang         = @da, @daniel, @carlos, [email protected]   # the five of them

A group is nothing but a shortcut standing for several things, and it may cite other shortcuts, so a team is written once and quoted everywhere; the file is followed round as far as it goes and each address is invited once however many paths lead to it. A line may also be a plain alias—da = @daniele—which is how a long imported name gets the two letters I actually type. -c lists them all and -x @psav shows what a group really comes to, but the book is mostly kept by three options rather than by hand:

af2cal --add kavokin [email protected] Alexey Kavokin   # a new one
af2cal --add kavokin [email protected]                    # a second address for him
af2cal --add psav @kavokin                                      # into a group
af2cal --set kavokin [email protected]                  # replacing what was there
af2cal --rm  kavokin                                            # and out again

--add on a name that is already in the book adds the address to it instead of refusing, which is how a second address reaches a man who already has one, and an address already there is not written twice. Whatever is neither an address nor an @shortcut becomes the note that TAB shows beside him. --rm says who still names the shortcut it has just removed, since a group citing a name that no longer exists would stop the next event dead. Every one of them copies the file to ~/.af2cal-contacts.bak first and rewrites the single line it must, leaving the comments and the order alone. TAB after --add, --set or --rm completes the names already in the book.

A Google Contacts export goes in wholesale with --import, which is the way to fill the book without an OAuth client anywhere in sight: Contacts → Export → Google CSV (or vCard), then af2cal --import ~/Downloads/contacts.csv. Every contact becomes a shortcut under his first name when that name is free—then first+last, then a digit—and every label with more than one member becomes a group, so a label kept in Google is a group here for nothing. Only the first address of a person is kept: an invitation sent to three addresses of the same man invites three men, and the others are written into the comment where they can be read and moved by hand. Google's own Imported on 1/27 bookkeeping labels are not groups of anybody and are dropped. Nothing is uploaded, nothing is synchronised: the file is a copy, and mine—144 contacts came in as 93 shortcuts and 13 groups, the difference being everyone I have a telephone number for and no address.

What an export cannot bring is what Google does not hold. Fifty-one of those 144 cards carry a telephone number and no address at all—Kavokin, Ballarini, Klembt, Finley, Nalitov, Bastarrachea, Colas among the physicists, Contestador Roaming and Policia Tres Cantos among the rest—and Other contacts, the pile Gmail collects by itself, is empty on my account, so there is no second export to run. The addresses are not lost, of course: they are in my mail, where a search for the surname turns up [email protected] on a thread from this week. But nothing can guess which address of a man I mean, nor which Elena is which, and a wrong guess here does not sit quietly in a file—it puts a stranger on an invitation. So they go in one at a time, on the day I need them, which is what the next three options are for.

TAB shows, it does not choose. A prefix that several shortcuts answer prints them with what each one stands for, and gives the word back exactly as it was typed:

$ af2cal 02HUG lunch @alex⇥
@alex                [alejandro…@gmail.com]            Alex Gonzalez
@alexandros          [[email protected]]        Alexandros Spiliotis
@alexandrosspiliotis [[email protected]]  Alexandros Spiliotis
$ af2cal 02HUG lunch @alex

so the way to discriminate is to go on typing—@alexa, @alexan—until one shortcut is left, and only then does the alias give way to the address it stands for. That is the whole of the rule, and it settles a knot the earlier versions could not: a name that is also the beginning of another one can never be completed away, since @alexandros would for ever be blocked by @alexandrosspiliotis. It does not need to be. The word stands as written and af2cal resolves it exactly when the event is made, saying as it goes taking @alexandros exactly; @alexandrosspiliotis start the same way—a choice announced rather than a choice made. A group behaves the same, expanding to its addresses when its name is the only one left.

Two mechanical notes, since neither is obvious. Bash can only offer words it is prepared to insert, and [an address] is no part of the word, so the list is printed on the terminal by hand; readline then does not redisplay a word handed back unchanged, and the prompt and the line have to be written out again underneath, which is what ${PS1@P} and COMP_LINE are doing there. And the completion asks the cheap question: --match @alex resolves the three shortcuts that begin that way and no others, where the first version resolved the whole book on every single TAB and cost 1.3 s of silence a keystroke; it is now 40 ms. The completion lives in ~/.local/share/bash-completion/completions/af2cal—and bash keeps the copy it loaded on the first TAB, so a new version wants a new terminal, or complete -r af2cal; source it again.

What it builds

https://calendar.google.com/calendar/render?action=TEMPLATE
   &text=seminar+on+polaritons
   &dates=20260823T125106Z/20260823T142106Z
   &details=AF%3A+02HUG
   &location=the+blue+room
   &add=someone%40some.where

The times go out in UTC, with the Z, so the time zone never has to be declared and a six-character tag—one written outside Europe—lands on the right instant anyway. The description always opens with the tag, which means the event can be read back with af 02HUG long after the fact: the calendar entry and the equation label and the code comment all carry the same five characters, which was the whole point of AF in the first place.

The other half of the traffic is on Google calendar, where the calendar itself is put on the wiki—and where the way back, the calendar writing into the diary by itself, is still waiting on a script.

Notes

  • Bash, nothing to install. The script is ~/bin/af2cal; it calls af and needs nothing else, beyond python3 for --import alone.
  • It always decodes with af -b, forcing the backwards reading. Without it af is entitled to read a five-character word as a date and hand back a tag instead of a time—the very ambiguity the -b flag exists for.
  • The address is handed to xdg-open, which on this machine is google-chrome-wrapper, so the tab opens on the virtual desktop I am actually looking at rather than wherever Chrome last had a window.
  • An accent very nearly escaped: a shell loop that url-encodes character by character encodes characters, and the é of Café went out as the Latin-1 %E9 that Google renders as a black diamond. Encoding under LC_ALL=C makes the loop walk bytes instead, and the same é comes out as the %C3%A9 it should always have been.
  • An address that ends in a full stop is a typo and not a domain—one came in from Google that way and the whole line was refused as "neither an address nor an @shortcut". Trailing punctuation is now trimmed on the way in and on the way out.
  • A shell trap worth writing down: a function that dies inside $( ) stops only its own subshell, and if the substitution ends in a pipe—$(resolve | paste)—the status that comes back is paste's, which is always 0. An unknown @shortcut therefore printed its complaint and made the event anyway, with nobody invited. Joining the addresses in the shell instead of through a pipe restores the status, and the error stops the run.
  • The title is everything the guests leave behind, so no quoting is needed—af2cal 02HUG lunch @da at the market—and a bare tag becomes its own title, which is a decent way to reserve a slot before knowing what it is for.
#!/bin/bash
# af2cal — v1.5.0 (22 Aug 2026)
#
# An AF tag straight into Google Calendar: af2cal 02HUG seminar on polaritons
# decodes the tag with ~/bin/AF, builds a Google Calendar TEMPLATE url with that
# instant as the start, the tag in the description, and hands it to the browser.
# One click on Save and the meeting is there. No OAuth, no API, no Cloud console.
#
# v1.2.0: guests. An address written among the words is a guest, and so is an
# @shortcut out of ~/.af2cal-contacts, which may stand for one address or for a
# whole group. TAB completes them (bash completion, --contacts, --expand).

VERSION=1.5.0
AF=${AF_BIN:-af}
DEFAULT_MIN=60
CONTACTS=${AF2CAL_CONTACTS:-$HOME/.af2cal-contacts}

usage() {
cat <<EOF
af2cal — v$VERSION — an AF tag becomes a Google Calendar event

  af2cal TAG [title words...] [@who] [address...]
                                 e.g.  af2cal 02HUG seminar @psav
  af2cal "TAG title words..."    (the tag may be the first word of one string)

  -d, --duration DUR   45, 90m, 1h, 1h30, 2d      (default ${DEFAULT_MIN}m)
  -e, --end TAG        end at a second AF tag instead of a duration
  -a, --all-day        an all-day entry on the tag's date
  -l, --location TEXT  where
  -m, --memo TEXT      extra lines under the tag in the description
  -w, --with WHO       guests: addresses and @shortcuts, comma-separated
  -u, --url            print the url, do not open it
  -n, --dry-run        show what would be created (decoded time + url)
  -h, --help           this        -v, --version   version

Guests, from $CONTACTS:
  -c, --contacts [PAT] list the address book, PAT keeping the lines that
                       match it -- who @alex and @alexandros really are
  -x, --expand WHO     resolve @shortcuts to addresses and print them
      --add NAME WHO   put a shortcut in the address book -- or, when the name
                       is already there, ADD the address to it. Words that are
                       neither an address nor an @shortcut become the note:
                         af2cal --add kavokin [email protected] Alexey
      --set NAME WHO   the same, replacing what the name stood for
      --rm NAME        take a shortcut out
      --import FILE    read a Google Contacts export (.csv or .vcf), labels
                       becoming groups

A guest may be written anywhere among the words: af2cal 02HUG lunch @da makes
the lunch and invites him. Google asks whether to send the invitations when the
event is saved. The description always carries the tag, so the event can be read
back with 'af TAG' -- see [[AF]].
EOF
}

die() { echo "af2cal: $*" >&2; exit 2; }

urlenc() {                      # RFC 3986, byte by byte (so accents survive)
    local LC_ALL=C s=$1 o= c i
    for (( i=0; i<${#s}; i++ )); do
        c=${s:i:1}
        case $c in
            [-_.~a-zA-Z0-9]) o+=$c ;;
            ' ')             o+='+' ;;
            *)               o+=$(printf '%%%02X' "'$c") ;;
        esac
    done
    printf '%s' "$o"
}

# "90m", "1h30", "2h", "45", "1d" -> seconds
dur2sec() {
    local d=${1,,} s=0
    [[ $d =~ ^[0-9]+$ ]] && { echo $(( d * 60 )); return; }
    [[ $d =~ ^([0-9]+d)?([0-9]+h)?([0-9]+m?)?$ ]] || die "cannot read the duration '$1'"
    [[ $d =~ ([0-9]+)d ]] && s=$(( s + BASH_REMATCH[1] * 86400 ))
    [[ $d =~ ([0-9]+)h ]] && s=$(( s + BASH_REMATCH[1] * 3600 ))
    [[ $d =~ ([0-9]+)h([0-9]+)$ ]] && { s=$(( s + BASH_REMATCH[2] * 60 )); d=${d%%h*}h; }
    [[ $d =~ ([0-9]+)m ]] && s=$(( s + BASH_REMATCH[1] * 60 ))
    (( s > 0 )) || die "a duration of nothing"
    echo "$s"
}

istag()   { [[ $1 =~ ^[0-9A-Za-z]{5}[A-Za-z]?$ ]]; }
isemail() { [[ $1 =~ ^[^@[:space:],]+@[^@[:space:],]+\.[A-Za-z]{2,}$ ]]; }
isref()   { [[ $1 =~ ^@[A-Za-z0-9_.+-]+$ ]]; }

decode() {                      # tag -> epoch, on stdout
    local out
    istag "$1" || die "'$1' is not an AF tag (5 characters, 6 across a time zone)"
    out=$("$AF" -b "$1") || die "$AF could not read '$1'"
    out=${out%%$'\t'*}
    [[ $out =~ ^[0-9]+$ ]] || die "$AF gave back no epoch for '$1'"
    echo "$out"
}

# ------------------------------------------------------- the address book ----
#
# One shortcut a line:  name = address[, address | @othershortcut]...   # note
# A group is only a shortcut standing for several things, and it may cite other
# shortcuts, so the five of a team are written once and quoted everywhere.

declare -A BOOK=() NOTE=()
BOOK_READ=

read_book() {
    [[ -n $BOOK_READ ]] && return
    BOOK_READ=1
    [[ -r $CONTACTS ]] || return
    local line name value note
    while IFS= read -r line || [[ -n $line ]]; do
        note=
        [[ $line == *\#* ]] && note=${line#*\#}   # keep the name for -c
        line=${line%%\#*}
        line=${line#"${line%%[![:space:]]*}"}   # and blanks either end
        line=${line%"${line##*[![:space:]]}"}
        [[ -z $line ]] && continue
        if [[ $line == *=* ]]; then name=${line%%=*}; value=${line#*=}
        else                        name=${line%%[[:space:]]*}; value=${line#"$name"}; fi
        name=${name#@}
        name=${name%"${name##*[![:space:]]}"}
        value=${value#"${value%%[![:space:]]*}"}
        if [[ -n $name && -n $value ]]; then
            BOOK[${name,,}]=$value
            note=${note#"${note%%[![:space:]]*}"}
            NOTE[${name,,}]=$note
        fi
    done < "$CONTACTS"
}

# resolve WHO... -> one address a line. @shortcuts are followed, once each.
resolve() {
    read_book
    local -A seen=() out=()
    local -a queue=("$@")
    local depth=0 item tok
    while (( ${#queue[@]} )); do
        (( ++depth > 200 )) && die "the address book turns in circles"
        item=${queue[0]}; queue=("${queue[@]:1}")
        item=${item//,/ }
        for tok in $item; do
            tok=${tok%%[.,;:]}
            if isemail "$tok"; then out[${tok,,}]=$tok
            elif isref "$tok"; then
                local key=${tok#@}; key=${key,,}
                [[ -n ${seen[$key]} ]] && continue
                seen[$key]=1
                [[ -n ${BOOK[$key]} ]] || die "no '@$key' in $CONTACTS (af2cal -c lists them)"
                siblings "$key"          # an exact name is used, never silently
                queue+=("${BOOK[$key]}")
            elif [[ -n $tok ]]; then die "'$tok' is neither an address nor an @shortcut"
            fi
        done
    done
    local a; for a in "${out[@]}"; do echo "$a"; done
}

# the same, comma-joined, and — unlike a pipe into paste — it keeps resolve's
# exit status, so an unknown @shortcut stops the run instead of quietly
# producing an event with nobody invited.
resolve_csv() {
    local out
    out=$(resolve "$@") || return $?
    printf '%s' "${out//$'\n'/,}"
}

# @alex is Alex Gonzalez exactly, but @alexandros and @alexandrosspiliotis
# start the same way: say so rather than let the choice pass unremarked.
siblings() {
    [[ -n $QUIET_SIBLINGS ]] && return 0   # the listings show the family anyway
    local key=$1 k also=()
    for k in $(printf '%s\n' "${!BOOK[@]}" | sort); do
        [[ $k != "$key" && $k == "$key"* ]] && also+=("@$k")
    done
    (( ${#also[@]} )) || return 0
    local list; list=$(printf '%s, ' "${also[@]}"); list=${list%, }
    echo "af2cal: taking @$key exactly; $list start the same way" >&2
}

# just the names, resolving nothing: the completion asks for this on every TAB
# and a hundred resolutions to throw away took more than a second of it.
list_names() {
    read_book
    printf '%s\n' "${!BOOK[@]}" | sort
}

# --match @alex: the shortcuts beginning that way, each with what it stands
# for, for the completion to SHOW without choosing. Only the matches are
# resolved, so it stays instant however long the book grows.
list_match() {
    local pre=${1#@} k n w=0 out=() QUIET_SIBLINGS=1
    read_book
    for k in $(printf '%s\n' "${!BOOK[@]}" | sort); do
        [[ ${k,,} == "${pre,,}"* ]] || continue
        (( ${#k} > w )) && w=${#k}
        out+=("$k")
    done
    for k in "${out[@]}"; do
        printf "@%-*s [%s]%s\n" "$w" "$k" "$(resolve_csv "@$k")" \
               "${NOTE[$k]:+  ${NOTE[$k]}}"
    done
}

list_book() {                   # -c [PATTERN]: PATTERN filters name and note
    read_book
    (( ${#BOOK[@]} )) || { echo "af2cal: no address book yet ($CONTACTS)"; return; }
    local pat=${1#@} k QUIET_SIBLINGS=1
    for k in $(printf '%s\n' "${!BOOK[@]}" | sort); do
        if [[ -n $pat ]]; then
            [[ ${k,,} == *"${pat,,}"* || ${NOTE[$k],,} == *"${pat,,}"* ]] || continue
        fi
        printf '@%-12s %-32s %s\n' "$k" "$(resolve_csv "@$k")" "${NOTE[$k]}"
    done
}

# --add NAME thing...   an address, an @shortcut, or words that are neither and
#                       become the note. A name already in the book has the
#                       addresses ADDED to it -- that is how a second address
#                       reaches a man who already has one.
# --set NAME thing...   the same, but replacing what was there.
# --rm  NAME            the line goes.
add_contact() {
    local mode=$1 name=${2#@}; shift 2
    [[ -n $name ]] || die "--$mode wants a name"
    isref "@$name" || die "'$name' is not a usable shortcut name"
    local key=${name,,} QUIET_SIBLINGS=1
    read_book

    if [[ $mode == rm ]]; then
        [[ -n ${BOOK[$key]} ]] || die "no '@$key' in $CONTACTS"
        local k cited=()
        for k in "${!BOOK[@]}"; do [[ ${BOOK[$k]} == *"@$key"* ]] && cited+=("@$k"); done
        book_write "$key" '' ''
        echo "@$key is gone${cited:+ (still named in ${cited[*]})}"
        return
    fi

    local -a addrs=() words=()
    local t
    for t in "$@"; do
        if isemail "${t%%[.,;:]}" || isref "$t"; then addrs+=("${t%%[.,;:]}")
        else words+=("$t"); fi
    done
    (( ${#addrs[@]} )) || die "--$mode wants at least one address or @shortcut"

    local value note
    if [[ $mode == add && -n ${BOOK[$key]} ]]; then
        value=${BOOK[$key]}
        for t in "${addrs[@]}"; do
            [[ ,${value//[[:space:]]/}, == *,"$t",* ]] && continue   # already there
            value+=", $t"
        done
        note=${NOTE[$key]}
        [[ ${#words[@]} -gt 0 ]] && note=${words[*]}
    else
        value=$(printf '%s, ' "${addrs[@]}"); value=${value%, }
        note=${words[*]}
    fi

    book_write "$key" "$value" "$note"
    BOOK_READ=; read_book
    list_match "@$key"
}

# rewrite one line of the book, keeping every other line and its comments as
# they are; an empty value deletes it. The file is backed up first.
book_write() {
    local key=$1 value=$2 note=$3 tmp line name found=
    [[ -e $CONTACTS ]] || printf '# af2cal address book: name = address, address, @othershortcut\n' > "$CONTACTS"
    cp -p "$CONTACTS" "$CONTACTS.bak"
    tmp=$(mktemp "${CONTACTS}.XXXXXX") || die "cannot write beside $CONTACTS"
    while IFS= read -r line || [[ -n $line ]]; do
        name=${line%%=*}
        name=${name#@}
        name=${name%"${name##*[![:space:]]}"}
        name=${name#"${name%%[![:space:]]*}"}
        if [[ $line != \#* && $line == *=* && ${name,,} == "$key" ]]; then
            found=1
            [[ -n $value ]] && printf '%-12s = %s%s\n' "$key" "$value" "${note:+   # $note}" >> "$tmp"
        else
            printf '%s\n' "$line" >> "$tmp"
        fi
    done < "$CONTACTS"
    [[ -z $found && -n $value ]] && printf '%-12s = %s%s\n' "$key" "$value" "${note:+   # $note}" >> "$tmp"
    mv "$tmp" "$CONTACTS"
    chmod 600 "$CONTACTS"
}

import_contacts() {             # --import FILE  (Google Contacts .csv or .vcf)
    local file=$1
    [[ -r $file ]] || die "cannot read $file"
    command -v python3 >/dev/null || die "--import needs python3"
    [[ -e $CONTACTS ]] || printf '# af2cal address book: name = address, address, @othershortcut\n' > "$CONTACTS"
    python3 - "$file" "$CONTACTS" <<'PY'
import csv, io, re, sys, unicodedata
src, book = sys.argv[1], sys.argv[2]

def slug(s):
    s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode()
    s = re.sub(r'[^A-Za-z0-9]+', '', s).lower()
    return s

people, groups = [], {}          # (shortcut, name, [addresses]) ; label -> set

def add_person(name, mails, labels):
    mails = [m for m in mails if m and '@' in m]
    if not mails:
        return
    # the first name when it is free, then first+last, then the lot: @da<TAB>
    # is meant to reach Daniele, not danielesanvitto.
    parts = name.split()
    cands = [slug(parts[0]) if parts else '',
             slug(''.join(parts[:1] + parts[-1:])),
             slug(name),
             slug(mails[0].split('@')[0])]
    cands = [c for c in cands if c]
    short = cands[0]
    people.append((short, name, mails, cands))
    for lab in labels:
        lab = slug(lab)
        # Google's own bookkeeping labels are not groups of anybody
        if lab and lab not in ('mycontacts', 'starred') and not lab.startswith('importedon'):
            groups.setdefault(lab, set()).add(short)

if src.lower().endswith('.vcf'):
    card = {}
    for line in open(src, encoding='utf-8', errors='replace'):
        line = line.rstrip('\r\n')
        if line.upper().startswith('BEGIN:VCARD'):
            card = {'mails': [], 'labels': [], 'name': ''}
        elif line.upper().startswith('FN:'):
            card['name'] = line[3:].strip()
        elif line.upper().startswith('EMAIL'):
            card['mails'].append(line.split(':', 1)[-1].strip())
        elif line.upper().startswith('CATEGORIES:'):
            card['labels'] += [x.strip() for x in line.split(':', 1)[-1].split(',')]
        elif line.upper().startswith('END:VCARD') and card:
            add_person(card['name'], card['mails'], card['labels'])
            card = {}
else:
    with open(src, encoding='utf-8-sig', errors='replace', newline='') as fh:
        for row in csv.DictReader(fh):
            keys = {k.lower(): k for k in row if k}
            name = (row.get(keys.get('name', ''), '') or
                    ' '.join(filter(None, [row.get(keys.get('first name', ''), ''),
                                           row.get(keys.get('last name', ''), '')]))).strip()
            mails = [v.strip() for k, v in row.items()
                     if k and re.match(r'e-?mail.*(value|address)', k.lower()) and v]
            mails = [m.strip('.,;: ') for one in mails
             for m in re.split(r'[ ,;:]+', one) if '@' in m]
            labels = []
            for k in ('labels', 'group membership'):
                if keys.get(k) and row.get(keys[k]):
                    labels += re.split(r'\s*:::\s*|\s*,\s*', row[keys[k]])
            add_person(name, mails, labels)

seen, out = set(), []
for line in open(book, encoding='utf-8'):
    m = re.match(r'\s*@?([A-Za-z0-9_.+-]+)\s*=', line)
    if m:
        seen.add(m.group(1).lower())

def emit(short, value, comment='', cands=()):
    for c in cands:                        # a taken shortcut tries the next form
        if c not in seen:
            short = c
            break
    n, s = 0, short
    while s in seen:                       # a taken shortcut grows a digit
        n += 1
        s = f'{short}{n}'
    seen.add(s)
    out.append(f'{s:<12} = {value}' + (f'   # {comment}' if comment else ''))
    return s

taken = {}
for short, name, mails, cands in people:
    note = name + (' (also ' + ', '.join(mails[1:]) + ')' if len(mails) > 1 else '')
    taken[short] = emit(short, mails[0], note, cands)
for lab, members in sorted(groups.items()):
    if len(members) > 1:
        emit(lab, ', '.join('@' + taken[m] for m in sorted(members) if m in taken), 'group')

if out:
    with open(book, 'a', encoding='utf-8') as fh:
        fh.write('\n# imported from ' + src + '\n' + '\n'.join(out) + '\n')
print(f'af2cal: {len(people)} contact(s) and {sum(1 for g in groups.values() if len(g) > 1)} group(s) added to {book}', file=sys.stderr)
PY
    chmod 600 "$CONTACTS"
}

DUR= ENDTAG= ALLDAY= LOC= MEMO= GUESTS= URLONLY= DRY=
while [[ $1 == -* ]]; do
    case $1 in
        -d|--duration) DUR=$2;    shift 2 ;;
        -e|--end)      ENDTAG=$2; shift 2 ;;
        -a|--all-day)  ALLDAY=1;  shift ;;
        -l|--location) LOC=$2;    shift 2 ;;
        -m|--memo)     MEMO=$2;   shift 2 ;;
        -w|--with)     GUESTS+="${GUESTS:+,}$2"; shift 2 ;;
        -u|--url)      URLONLY=1; shift ;;
        -n|--dry-run)  DRY=1;     shift ;;
        -c|--contacts) list_book "$2"; exit 0 ;;
        --names)       list_names; exit 0 ;;
        --match)       list_match "$2"; exit 0 ;;
        -x|--expand)   shift; [[ $# -gt 0 ]] || die "--expand wants an @shortcut"
                       resolve_csv "$@" || exit $?; echo; exit 0 ;;
        --add|--set|--rm)
                       t=${1#--}; shift; add_contact "$t" "$@"; exit 0 ;;
        --import)      import_contacts "$2"; exit 0 ;;
        -h|--help)     usage; exit 0 ;;
        -v|--version)  echo "af2cal $VERSION"; exit 0 ;;
        --)            shift; break ;;
        *)             die "unknown option $1 (af2cal -h)" ;;
    esac
done

[[ $# -gt 0 ]] || { usage; exit 2; }

# The tag is the first word, whether it came as its own argument or not.
if istag "$1"; then TAG=$1; shift
else TAG=${1%% *}; set -- "${1#"$TAG"}" "${@:2}"; set -- "${1# }" "${@:2}"; fi

START=$(decode "$TAG") || exit $?

# Addresses and @shortcuts are guests wherever they stand; the rest is the title.
WORDS=()
for w in "$@"; do
    if isemail "$w" || isref "$w"; then GUESTS+="${GUESTS:+,}$w"
    else WORDS+=("$w"); fi
done
TITLE="${WORDS[*]}"; TITLE=${TITLE#"${TITLE%%[![:space:]]*}"}
[[ -n $TITLE ]] || TITLE=$TAG

INVITED=
if [[ -n $GUESTS ]]; then INVITED=$(resolve_csv "$GUESTS") || exit $?; fi

if [[ -n $ENDTAG ]]; then
    END=$(decode "$ENDTAG") || exit $?
    (( END > START )) || die "the end tag $ENDTAG is not after $TAG"
else
    SEC=$(dur2sec "${DUR:-${DEFAULT_MIN}m}") || exit $?
    END=$(( START + SEC ))
fi

if [[ -n $ALLDAY ]]; then
    DATES="$(date -d "@$START" +%Y%m%d)/$(date -d "@$(( END > START + 86400 ? END : START + 86400 ))" +%Y%m%d)"
else
    DATES="$(date -ud "@$START" +%Y%m%dT%H%M%SZ)/$(date -ud "@$END" +%Y%m%dT%H%M%SZ)"
fi

DETAILS="AF: $TAG"
[[ -n $MEMO ]] && DETAILS+=$'\n\n'"$MEMO"

URL="https://calendar.google.com/calendar/render?action=TEMPLATE"
URL+="&text=$(urlenc "$TITLE")"
URL+="&dates=$DATES"
URL+="&details=$(urlenc "$DETAILS")"
[[ -n $LOC     ]] && URL+="&location=$(urlenc "$LOC")"
[[ -n $INVITED ]] && URL+="&add=$(urlenc "$INVITED")"

WHEN=$("$AF" -b "$TAG" | cut -f3)
if [[ -n $URLONLY ]]; then
    echo "$URL"
    exit 0
fi
echo "$TAG$WHEN${ALLDAY:+ (all day)}"
echo "$TITLE"
[[ -n $INVITED ]] && echo "with ${INVITED//,/, }"
[[ -n $DRY ]] && { echo "$URL"; exit 0; }

if   command -v xdg-open              >/dev/null; then setsid xdg-open "$URL" >/dev/null 2>&1 &
elif command -v google-chrome-wrapper >/dev/null; then setsid google-chrome-wrapper "$URL" >/dev/null 2>&1 &
elif command -v google-chrome-stable  >/dev/null; then setsid google-chrome-stable "$URL" >/dev/null 2>&1 &
else die "no browser to open it with — try af2cal -u"; fi
# bash completion for af2cal — v2.2 (22 Aug 2026)
#
# TAB on an @shortcut SHOWS, it does not choose:
#
#   @alex<TAB>   @alex                [alejandro…@gmail.com]        Alex Gonzalez
#                @alexandros          [[email protected]]    Alexandros Spiliotis
#                @alexandrosspiliotis [alexkspiliotis@westlake…]    Alexandros Spiliotis
#
# and the word is left exactly as typed, so the way to discriminate is to go on
# typing -- @alexa, @alexan -- until one shortcut is left, and only THEN does
# the alias give way to the address(es) it stands for. A shortcut that is also
# the beginning of another one is never expanded by TAB, and does not need to
# be: af2cal resolves it exactly when the event is made.
#
# The list is printed on the terminal ourselves, since bash can only offer
# words it is prepared to insert, and "[address]" is no part of the word.

_af2cal() {
    local cur prev lines n
    cur=${COMP_WORDS[COMP_CWORD]}
    prev=${COMP_WORDS[COMP_CWORD-1]}

    case $prev in
        -d|--duration) COMPREPLY=( $(compgen -W "30m 45 1h 1h30 2h 3h 1d 2d" -- "$cur") ); return ;;
        -l|--location|-m|--memo) return ;;
        --import) COMPREPLY=( $(compgen -f -- "$cur") ); return ;;
        --add|--set|--rm)                       # the names, bare: --add kavokin
                  COMPREPLY=( $(compgen -W "$(af2cal --names 2>/dev/null)" -- "$cur") ); return ;;
    esac

    if [[ $cur == @* ]]; then
        mapfile -t lines < <(af2cal --match "$cur" 2>/dev/null)
        case ${#lines[@]} in
            0) COMPREPLY=() ;;
            1) COMPREPLY=( "$(af2cal --expand "${lines[0]%% *}" 2>/dev/null)" )
               [[ -n ${COMPREPLY[0]} ]] || COMPREPLY=( "${lines[0]%% *}" ) ;;
            *) # readline does not redisplay a word we hand back unchanged, so
               # the prompt and the line are written out again underneath.
               { printf '\n'; printf '%s\n' "${lines[@]}"
                 printf '%s%s' "${PS1@P}" "$COMP_LINE"; } > /dev/tty
               COMPREPLY=( "$cur" )          # the word is given back untouched
               compopt -o nospace 2>/dev/null ;;
        esac
        return
    fi

    if [[ $cur == -* ]]; then
        COMPREPLY=( $(compgen -W "-d -e -a -l -m -w -u -n -c -x -h -v
            --duration --end --all-day --location --memo --with --url --dry-run
            --contacts --names --match --expand --add --set --rm --import
            --help --version" -- "$cur") )
    fi
}
complete -o default -F _af2cal af2cal