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.
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]
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
|
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.
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.
~/bin/af2cal; it calls af and needs nothing else, beyond python3 for --import alone.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.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.é 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.$( ) 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.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