initial commit: readonly

This commit is contained in:
Ämin Baumeler 2025-07-15 13:41:20 +02:00
commit 4ba328934b
26 changed files with 1956 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
fzf-contact

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright © 2025 Ämin Baumeler
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.

5
README.md Normal file
View File

@ -0,0 +1,5 @@
A [fzf](https://github.com/junegunn/fzf)-based **address book** with CalDav support.
License
-------
This project is licensed under the [MIT License](./LICENSE).

19
scripts/build.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
BOLD="\033[1m"
GREEN="\033[0;32m"
OFF="\033[m"
NAME="fzf-contact"
SRC="./src/main.sh"
tmpdir=$(mktemp -d)
echo "🐔 ${GREEN}Internalize sourced files${OFF}"
sed -E 's|\. "([^$].+)"$|cat src/\1|e' "$SRC" >"$tmpdir/1.sh"
echo "🥚 ${GREEN}Internalize awk scripts${OFF}"
sed -E 's|@@include (.+)$|cat src/\1|e' "$tmpdir/1.sh" >"$tmpdir/2.sh"
echo "🐔 ${GREEN}Internalize awk libraries${OFF}"
sed -E 's|@include "(.+)"$|cat src/\1|e' "$tmpdir/2.sh" >"$NAME"
echo "🥚 ${GREEN}Make executable and cleanup${OFF}"
chmod +x "$NAME"
rm -rf "$tmpdir"
echo "🍳 ${GREEN}Done:${OFF} Sucessfully built ${BOLD}${GREEN}$NAME${OFF}"

41
src/awk/altertodo.awk Normal file
View File

@ -0,0 +1,41 @@
# Increase/decrease priority, or toggle completed status
#
# If `delta` is specified using `-v`, then the priority value is increased by
# `delta.` If `delta` is unspecified (or equal to 0), then the completeness
# status is toggled.
BEGIN {
FS=":";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
delta = delta + 0; # cast as integer
}
/^END:VTODO/ && inside {
# Print sequence and last-modified, if not yet printed
if (!seq) print "SEQUENCE:1";
if (!lm) print "LAST-MODIFIED:" zulu;
# Print priority
prio = prio ? prio + delta : 0 + delta;
prio = prio < 0 ? 0 : prio;
prio = prio > 9 ? 9 : prio;
print "PRIORITY:" prio;
# Print status (toggle if needed)
bit_status = status == "COMPLETED" ? 1 : 0;
bit_toggle = delta ? 0 : 1;
percent = xor(bit_status, bit_toggle) ? 100 : 0;
status = xor(bit_status, bit_toggle) ? "COMPLETED" : "NEEDS-ACTION";
print "STATUS:" status
print "PERCENT-COMPLETE:" percent
# print rest
inside = "";
print $0;
next
}
/^BEGIN:VTODO/ { inside = 1; print; next }
/^SEQUENCE/ && inside { seq = 1; print "SEQUENCE:" $2+1; next }
/^LAST-MODIFIED/ && inside { lm = 1; print "LAST-MODIFIED:" zulu; next }
/^PRIORITY:/ && inside { prio = $2; next }
/^STATUS/ && inside { status = $2; next }
/^PERCENT-COMPLETE/ && inside { next } # ignore, we take STATUS:COMPLETED as reference
{ print }

35
src/awk/attach.awk Normal file
View File

@ -0,0 +1,35 @@
## src/awk/attach.awk
## Prepend attachment to iCalendar file.
##
## @assign file: Path to base64-encoded content
## @assign mime: Mime
## @assign filename: Original filename
# Functions
# Write attachment
#
# @local variables: line, aline
function write_attachment( line, aline, fl) {
line = "ATTACH;ENCODING=BASE64;VALUE=BINARY;FMTTYPE="mime";FILENAME="filename":"
fl = 1
while (getline aline <file) {
line = line aline
if (fl && length(line) >= 73) {
print substr(line, 1, 73)
line = substr(line, 74)
fl = 0
}
while (length(line) >= 72) {
print " "substr(line, 1, 72)
line = substr(line, 73)
}
}
if (line)
print " "line
}
# AWK program
/^END:(VTODO|VJOURNAL)$/ { write_attachment() }
{ print }

8
src/awk/attachdd.awk Normal file
View File

@ -0,0 +1,8 @@
BEGIN { FS="[:;]" }
/^END:(VTODO|VJOURNAL)$/ { ins = 0; exit }
/^[^ ]/ && a { a = 0 }
/^ / && a && p { print substr($0, 2); }
/^ / && a && !p { if (index($0, ":")) { p = 1; print substr($0, index($0, ":")+1) } }
/^ATTACH/ && ins { i++; }
/^ATTACH/ && ins && i == id { a = 1; if (index($0, ":")) { p = 1; print substr($0, index($0, ":")+1) } }
/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 }

41
src/awk/attachls.awk Normal file
View File

@ -0,0 +1,41 @@
# Decide if we need to read more to get all properties
#
# @input str: strin read so far
# @return: 1 if we need more data, 0 otherwise
function cont_reading(str) {
return index(str, ":") ? 0 : 1
}
# Get information about attachment
#
# @input i: Attachment index
# @input str: Attachment string (at least up to content separator `:`)
# @return: informative string
function att_info(i, str, cnt, k, info) {
str = substr(str, 1, index(str, ":") - 1)
cnt = split(str, props)
if (cnt > 1) {
for (k=2; k<=cnt; k++) {
pname = substr(props[k], 1, index(props[k], "=") - 1)
pvalu = substr(props[k], index(props[k], "=") + 1)
if (pname == "ENCODING" && pvalu = "BASE64")
enc = "base64"
if (pname == "FILENAME")
fin = pvalu
if (pname == "VALUE")
val = pvalu
if (pname == "FMTTYPE")
type = pvalu
}
if (enc)
info = "inline"
}
print i, fin, type, enc, info
}
BEGIN { FS="[:;]"; OFS="\t" }
/^END:(VTODO|VJOURNAL)$/ { ins = 0; exit }
l && !r { att_info(i, l); l = "" }
/^ / && r { l = l substr($0, 2); r = cont_reading($0) }
/^ATTACH/ && ins { i++; l = $0; r = cont_reading($0) }
/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 }

13
src/awk/attachrm.awk Normal file
View File

@ -0,0 +1,13 @@
## src/awk/attachrm.awk
## Remove attachment from iCalendar file.
##
## @assign id: Attachment number to remove
BEGIN { FS="[:;]" }
/^END:(VTODO|VJOURNAL)$/ { ins = 0 }
/^[^ ]/ && a { a = 0 }
/^ / && a { next }
/^ATTACH/ && ins { i++; }
/^ATTACH/ && ins && i == id { a = 1; next }
/^BEGIN:(VTODO|VJOURNAL)$/ { ins = 1 }
{ print }

49
src/awk/get.awk Normal file
View File

@ -0,0 +1,49 @@
# Retrieve content from iCalendar files
#
# Mandatory variable: `field`.
# Name of field to retrieve.
#
# Optional variable: `format`.
# If `format` is set to "csv", then the content is interpreted as
# comma-separated values, and empty values are dropped.
# If `format` is set to "date", then the content is interpreted as
# a date the output is in the form YYYY-MM-DD.
#
# Optional variable: `oneline`.
# If `oneline` is set, then the all newlines will be replaced by white spaces
@include "lib/awk/vcard.awk"
# print content of field `field`
BEGIN { FS = ":"; regex = "^" field; }
BEGINFILE { type = ""; line = ""; }
/^BEGIN:(VJOURNAL|VTODO)/ { type = $2 }
/^END:/ && $2 == type { nextfile }
$0 ~ regex { line = $0; next; }
/^ / && line { line = line substr($0, 2); next; }
/^[^ ]/ && line { nextfile }
ENDFILE {
if (type) {
# Process line
content = getcontent(line)
if (oneline)
content = singleline(content)
switch (format) {
case "csv" :
split(content, a, ",")
res = ""
for (i in a) {
if (a[i])
res = res "," a[i]
}
print substr(res, 2)
break
case "date" :
if (content)
print substr(parse_dt("", content), 1, 10)
break
default :
print content
break
}
}
}

85
src/awk/list.awk Normal file
View File

@ -0,0 +1,85 @@
# awk script to generate summary line for vCard entries
#
# See https://www.rfc-editor.org/rfc/rfc6350
#
# Limitations:
# - Use first FN, NICKNAME
# - Ignore properties of FN and NICKNAME
@include "lib/awk/vcard.awk"
BEGIN {
# We require the following variables to be set using -v
# collection_lables: ;-delimited collection=label strings
# nickname_format: format string when nickname is present (has two placeholders)
# fn_format: format string when nickname is absent (has one placeholders)
# group_label: label for group entries
# org_label: label for organizations
# location_label: label for locations
# individual_label: label for individuals
FS = "[:;]";
OFS = "\t"
# Collections
split(collection_labels, mapping, ";");
for (map in mapping)
{
split(mapping[map], m, "=");
collection2label[m[1]] = m[2];
}
}
BEGINFILE {
# Reset variables
nickname = ""
fn = ""
kind = ""
prop = ""
line = ""
delete c
}
FNR == 1 && /^BEGIN:VCARD/ { next }
FNR == 1 { nextfile } # This is not a vcard file
/^END:VCARD/ { nextfile } # Done, go to next file
/^(FN|NICKNAME)/ && c[$1] { prop = ""; next } # FN and NICKNAME may appear multiple times, take first
/^(FN|NICKNAME|KIND)/ { prop = $1; c[prop] = $0; next }
/^[^ ]/ && prop { prop = ""; next }
/^ / && prop { c[prop] = c[prop] substr($0, 2); next }
ENDFILE {
# Construct path
depth = split(FILENAME, path, "/")
fpath = path[depth-1] "/" path[depth]
# Collection name
collection = path[depth-1]
collection = collection in collection2label ? collection2label[collection] : collection
# Content lines
fn = singleline(unescape(getcontent(c["FN"])))
nicknames = getcontent(c["NICKNAME"])
if (nicknames) {
split(nicknames, a, ",")
for (i in a) {
if (a[i] == fn)
continue
nickname = nickname " / " a[i]
}
nickname = substr(nickname, 4)
}
kind = getcontent(c["KIND"])
switch(kind) {
case "group": kind = group_label; break
case "org": kind = org_label; break
case "location": kind = location_label; break
default: kind = individual_label; # "individual"
}
# Build line to be presented
line = nickname ? nickname_format : fn_format
# If nickname contains the string "<<fn>>", then the behaviour is unexpected
gsub("<<nickname>>", nickname, line)
gsub("<<fn>>", fn, line)
print line,
collection,
kind,
fpath
}

69
src/awk/new.awk Normal file
View File

@ -0,0 +1,69 @@
@include "lib/awk/vcard.awk"
BEGIN {
FS=":";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
}
desc { desc = desc "\\n" escape($0); next; }
/^::: \|>/ && !start { gsub("\"", ""); start = substr(zulu, 1, 8); next; }
/^::: <\|/ && !due { gsub("\"", ""); due = "D" substr($0, 8); next; }
/^# / && !summary { summary = "S" escape(substr($0, 3)); next; }
/^> / && !categories { categories = "C" escape_but_commas(substr($0, 3)); next; }
!$0 && !el { el = 1; next; }
!el { print "Unrecognized header on line "NR": " $0 > "/dev/stderr"; exit 1; }
{ desc = "D" escape($0); next; }
END {
# Sanitize input
type = due ? "VTODO" : "VJOURNAL"
due = substr(due, 2)
summary = substr(summary, 2)
categories = substr(categories, 2)
desc = substr(desc, 2)
if (categories) {
split(categories, a, ",")
categories = ""
for (i in a)
if (a[i])
categories = categories "," a[i]
categories = substr(categories, 2)
}
if (due) {
# Use command line `date` for parsing
cmd = "date -d \"" due "\" +\"%Y%m%d\"";
suc = cmd | getline due
close(cmd)
if (suc != 1)
exit 1
}
# print ical
print "BEGIN:VCALENDAR";
print "VERSION:2.0";
print "CALSCALE:GREGORIAN";
print "PRODID:-//fzf-vjour//awk//EN";
print "BEGIN:" type;
print "DTSTAMP:" zulu;
print "UID:" uid;
print "CLASS:PRIVATE";
print "CREATED:" zulu;
print "SEQUENCE:1";
print "LAST-MODIFIED:" zulu;
if (type == "VTODO")
{
print "STATUS:NEEDS-ACTION";
print "PERCENT-COMPLETE:0";
if (due)
print "DUE;VALUE=DATE:" due;
}
else
{
print "STATUS:FINAL";
if (start)
print "DTSTART;VALUE=DATE:" start;
}
if (summary) print_fold("SUMMARY:", summary);
if (categories) print_fold("CATEGORIES:", categories);
if (desc) print_fold("DESCRIPTION:", desc);
print "END:" type;
print "END:VCALENDAR"
}

341
src/awk/preview.awk Normal file
View File

@ -0,0 +1,341 @@
# Generate preview of vCard file
#
# Limitations:
# - Use first occurnece of FN, NICKNAME, ORG
# - other...
@include "lib/awk/vcard.awk"
# Returns type symbol fork "home" and "work" types.
#
# @input type: type string
# @return: symbol or empty string
function type_symbol(type) {
switch(type) {
case "home": return label_home
case "work": return label_work
default: return ""
}
}
# Returns phone symbol
# Empty string is mapped to the default "voice". Any other string not
# recognized resturns the empty string.
#
# @input type: type string
# @return: nonempty string, defaults to voice symbol
function type_tel_symbol(type, res) {
res = type_symbol(type)
if (res)
return res
switch(type) {
case "text": return label_tel_text
case "fax": return label_tel_fax
case "cell": return label_tel_cell
case "video": return label_tel_video
case "pager": return label_tel_pager
case "textphone": return label_tel_textphone
case "voice": return label_tel
case "": return label_tel
default: return ""
}
}
function preview_group() {
print "GROUP"
}
function preview_org() {
print "ORG"
}
function preview_location() {
print "LOC"
}
function preview_individual() {
# Parse unstructured name
fn = singleline(unescape(getcontent(c["FN"])))
nickname = getcontent(c["NICKNAME"])
split(nickname, nicknamearray, ",")
# Parse structured name
if (c["N"]) {
split(getcontent(c["N"]), a, ";")
# Family name
familyname = a[1]
gsub(",", " ", familyname) # Comma-separated list, just concat with WS
familyname = singleline(unescape(familyname))
# Given name
givenname = a[2]
gsub(",", " ", givenname) # Comma-separated list, just concat with WS
givenname = singleline(unescape(givenname))
# Additional name
additionalname = a[3]
gsub(",", " ", additionalname) # Comma-separated list, just concat with WS
additionalname = singleline(unescape(additionalname))
# Prefix
prefixname = a[4]
gsub(",", " ", prefixname) # Comma-separated list, just concat with WS
prefixname = singleline(unescape(prefixname))
# Suffix
suffixname = a[5]
gsub(",", " ", suffixname) # Comma-separated list, just concat with WS
suffixname = singleline(unescape(suffixname))
# Print
if (prefixname)
line = line " " prefixname
if (givenname)
line = line " " givenname
if (additionalname)
line = line " " additionalname
if (familyname)
line = line " " familyname
if (suffixname)
line = line ? line ", " suffixname : suffixname
line = substr(line, 2)
fullname = line
#if (line && line != fn && line != nickname)
# fullname = line
}
# Parse Tel
tel[0] = 0
delete tel[0]
if (n["TEL"]) {
k = 100
for (i=1; i<= n["TEL"]; i++) {
delete b
tmp = ""
nr = singleline(unescape(getcontent(c["TEL", i])))
type = tolower(getparam(c["TEL", i], "TYPE"))
gsub("\"", "", type)
split(type, b, ",")
for (j in b) {
tmp = tmp type_tel_symbol(b[j])
}
type = tmp
pref = tolower(getparam(c["TEL", i], "PREF"))
if (!pref) {
pref = k
k = k + 1
}
while (pref in tel)
pref = pref + 1
tel[pref] = nr
teltype[pref] = type
}
}
# Parse E-Mail
email[0] = 0
delete email[0]
if (n["EMAIL"]) {
k = 100
for (i=1; i<= n["EMAIL"]; i++) {
delete b
tmp = ""
nr = singleline(unescape(getcontent(c["EMAIL", i])))
type = tolower(getparam(c["EMAIL", i], "TYPE"))
gsub("\"", "", type)
split(type, b, ",")
for (j in b) {
tmp = tmp type_symbol(b[j])
}
type = tmp
pref = tolower(getparam(c["EMAIL", i], "PREF"))
if (!pref) {
pref = k
k = k + 1
}
while (pref in email)
pref = pref + 1
email[pref] = nr
emailtype[pref] = type
}
}
# Parse IMPP
impp[0] = 0
delete impp[0]
if (n["IMPP"]) {
k = 100
for (i=1; i<= n["IMPP"]; i++) {
delete b
tmp = ""
nr = singleline(unescape(getcontent(c["IMPP", i])))
type = tolower(getparam(c["IMPP", i], "TYPE"))
gsub("\"", "", type)
split(type, b, ",")
for (j in b) {
tmp = tmp type_symbol(b[j])
}
type = tmp
pref = tolower(getparam(c["IMPP", i], "PREF"))
if (!pref) {
pref = k
k = k + 1
}
while (pref in impp)
pref = pref + 1
impp[pref] = nr
impptype[pref] = type
}
}
# Parse Address
adr[0] = 0
delete adr[0]
if (n["ADR"]) {
k = 100
for (i=1; i<= n["ADR"]; i++) {
delete b
tmp = ""
content = getcontent(c["ADR", i])
split(content, aa, ";")
pobox = aa[1]
extad = aa[2]
street = aa[3]
locality = aa[4]
region = aa[5]
code = aa[6]
country = aa[7]
if (street)
adrstr = singleline(unescape(street))
if (locality) {
if (adrstr)
adrstr = adrstr "\n"
adrstr = adrstr singleline(unescape(locality))
}
if (region) {
if (!locality && adrstr)
adrstr = adrstr "\n"
adrstr = adrstr singleline(unescape(region))
}
if (code) {
if (!locality && !region && adrstr)
adrstr = adrstr "\n"
adrstr = adrstr singleline(unescape(code))
}
if (country) {
if (adrstr)
adrstr = adrstr "\n"
adrstr = adrstr singleline(unescape(country))
}
type = tolower(getparam(c["ADR", i], "TYPE"))
gsub("\"", "", type)
split(type, b, ",")
for (j in b) {
tmp = tmp type_symbol(b[j])
}
type = tmp
pref = tolower(getparam(c["ADR", i], "PREF"))
if (!pref) {
pref = k
k = k + 1
}
while (pref in adr)
pref = pref + 1
adr[pref] = adrstr
adrtype[pref] = type
}
}
# Parse birthday
bday = getcontent(c["BDAY"])
if (bday) {
bday_date[0] = 0
delete bday_date[0]
date_and_or_time(bday, bday_date)
omityear = getparam(c["BDAY"], "X-APPLE-OMIT-YEAR")
if (omityear == bday_date["year"])
bday_date["year"] = ""
}
# Print structured data
if (fullname)
print "# "fullname
# Phone numbers
if (length(tel) >= 1) {
print "\n## ☎️ Phone numbers"
for (pref in tel) {
style = pref == 1 ? "**" : ""
print "- " style teltype[pref] "\t" tel[pref] style
}
}
# E-Mail addresses
if (length(email) >= 1) {
print "\n## 📧 E-mail addresses"
for (pref in email) {
style = pref == 1 ? "**" : ""
print "- " style emailtype[pref] "\t" email[pref] style
}
}
# IMPP
if (length(impp) >= 1) {
print "\n## 💬 Instant messaging"
for (pref in impp) {
style = pref == 1 ? "**" : ""
print "- " style impptype[pref] "\t" impp[pref] style
}
}
# Address
if (length(adr) >= 1) {
for (pref in adr) {
print "\n## " adrtype[pref] " Postal address"
print adr[pref]
}
}
# Birthday
if (bday_date["month"] && bday_date["day"]) {
datestr = "2000-"bday_date["month"] "-" bday_date["day"]
gsub("\"", "", datestr)
cmd = "date -d \"" datestr "\" +\"%e %B\""
suc = cmd | getline res
close(cmd)
if (suc == 1) {
print "\n### 🎂 Birthday"
if (bday_date["year"])
print res " " bday_date["year"]
else
print res
}
}
# Nicknames
if (nickname) {
print "\n### 🏷️ Additional names"
for (i in nicknamearray)
print "- " nicknamearray[i]
}
}
BEGIN { IGNORECASE=1; FS = "[:;]" }
FNR == 1 && /^BEGIN:VCARD/ { next }
FNR == 1 { exit 1 } # This is not a vcard file
/^END:VCARD/ { exit }
/^(FN|NICKNAME|ORG)/ && c[$1] { prop = ""; next } # FN, NICKNAME, ORG may appear multiple times, take first
/^(FN|NICKNAME|KIND|N|BDAY|ORG)/ { prop = toupper($1); n[prop] = 0; c[prop] = $0; next }
/^(ADR|TEL|EMAIL|IMPP|GEO|TITLE|ROLE|ORG|URL)/ { # These entries may appear multiple times
prop = toupper($1); n[prop] = n[prop] + 1; c[prop, n[prop]] = $0; next }
/^[^ ]/ && prop { prop = ""; next }
/^ / && prop {
if (n[prop])
c[prop, n[prop]] = c[prop, n[prop]] substr($0, 2)
else
c[prop] = c[prop] substr($0, 2)
next
}
END {
kind = getcontent(c["KIND"])
switch(kind) {
case "group": preview_group(); break
case "org": preview_org(); break
case "location": preview_location(); break
default: preview_individual(); break
}
}

57
src/awk/update.awk Normal file
View File

@ -0,0 +1,57 @@
@include "lib/awk/vcard.awk"
BEGIN {
FS=":";
zulu = strftime("%Y%m%dT%H%M%SZ", systime(), 1);
}
ENDFILE {
if (NR == FNR) {
due = substr(due, 2)
summary = substr(summary, 2)
categories = substr(categories, 2)
desc = substr(desc, 2)
if (categories) {
split(categories, a, ",")
categories = ""
for (i in a)
if (a[i])
categories = categories "," a[i]
categories = substr(categories, 2)
}
if (due) {
# Use command line `date` for parsing
cmd = "date -d \"" due "\" +\"%Y%m%d\"";
suc = cmd | getline due
close(cmd)
if (suc != 1)
exit 1
}
}
}
NR == FNR && desc { desc = desc "\\n" escape($0); next; }
NR == FNR && /^::: <\|/ && !due { gsub("\"",""); due = "D" substr($0, 8); next; }
NR == FNR && /^# / && !summary { summary = "S" escape(substr($0, 3)); next; }
NR == FNR && /^> / && !categories { categories = "C" escape_but_commas(substr($0, 3)); next; }
NR == FNR && !$0 && !el { el = 1; next; }
NR == FNR && !el { print "Unrecognized header on line "NR": " $0 > "/dev/stderr"; exit 1; }
NR == FNR { desc = "D" escape($0); next; }
due && type == "VJOURNAL" { print "Notes and journal entries do not have due dates." > "/dev/stderr"; exit 1; }
/^BEGIN:(VJOURNAL|VTODO)/ { type = $2; print; next; }
/^ / && drop { next; } # drop this folded line
/^X-ALT-DESC/ && type { drop = 1; next; } # drop this alternative description
/^(DUE|SUMMARY|CATEGORIES|DESCRIPTION|LAST-MODIFIED)/ && type { drop = 1; next; } # skip for now, we will write updated fields at the end
{ drop = 0 } # keep everything else
/^SEQUENCE/ && type { seq = $2; next; } # store sequence number and skip
/^END:/ && type == $2 {
seq = seq ? seq + 1 : 1;
print "SEQUENCE:" seq;
print "LAST-MODIFIED:" zulu;
if (due) print "DUE;VALUE=DATE:" due;
print_fold("SUMMARY:", summary);
print_fold("CATEGORIES:", categories);
print_fold("DESCRIPTION:", desc);
type = "";
}
{ print }

159
src/lib/awk/vcard.awk Normal file
View File

@ -0,0 +1,159 @@
# Get date from date-and-or-time value
#
# @input str: date-and-or-time string
# @input arr: array, where arr["year"] arr["month"] arr["day"] will be the
# parsed date
function date_and_or_time(str, arr) {
# Some applications hyphenate the string.
# Year and month portions can be omitted by putting dashes.
if (index(str, "T"))
str = substr(str, 1, index(str, "T") - 1)
switch(length((str))) {
case 8:
arr["year"] = substr(str, 1, 4)
arr["month"] = substr(str, 5, 2)
arr["day"] = substr(str, 7, 2)
return
case 10:
arr["year"] = substr(str, 1, 4)
arr["month"] = substr(str, 6, 2)
arr["day"] = substr(str, 9, 2)
return
case 4:
arr["year"] = substr(str, 1, 4)
arr["month"] = ""
arr["day"] = ""
return
case 7:
arr["year"] = substr(str, 1, 4)
arr["month"] = substr(str, 6, 2)
arr["day"] = ""
return
case 6:
arr["year"] = ""
arr["month"] = substr(str, 3, 2)
arr["day"] = substr(str, 5, 2)
return
case 5:
arr["year"] = ""
arr["month"] = ""
arr["day"] = substr(str, 4, 2)
return
}
arr["year"] = ""
arr["month"] = ""
arr["day"] = ""
return
}
# Make string single-line
#
# @input str: String
# @return: String without newlines
function singleline(str) {
gsub("\\n", " ", str)
return str
}
# Escape string to be used as content in iCalendar files.
#
# @input str: String to escape
# @return: Escaped string
function escape(str)
{
gsub("\\\\", "\\\\", str)
gsub("\\n", "\\n", str)
gsub(";", "\\;", str)
gsub(",", "\\,", str)
return str
}
# Escape string to be used as content in iCalendar files, but don't escape
# commas.
#
# @input str: String to escape
# @return: Escaped string
function escape_but_commas(str)
{
gsub("\\\\", "\\\\", str)
gsub("\\n", "\\n", str)
gsub(";", "\\;", str)
return str
}
# Print property with its content and fold according to the iCalendar
# specification.
#
# @local variables: i, s
# @input nameparam: Property name with optional parameters
# @input content: Escaped content
function print_fold(nameparam, content, i, s)
{
i = 74 - length(nameparam)
s = substr(content, 1, i)
print nameparam s
s = substr(content, i+1, 73)
i = i + 73
while (s)
{
print " " s
s = substr(content, i+1, 73)
i = i + 73
}
}
# Unescape string
#
# @local variables: i, c, c2, res
# @input str: String
# @return: Unescaped string
function unescape(str, i, c, c2, res) {
for(i = 1; i <= length(str); i++) {
c = substr(str, i, 1)
if (c != "\\") {
res = res c
continue
}
i++
c2 = substr(str, i, 1)
if (c2 == "n" || c2 == "N") {
res = res "\n"
continue
}
# Alternatively, c2 is "\\" or "," or ";". In each case, append res with
# c2. If the strings has been escaped correctly, then the character c2
# cannot be anything else. To be fail-safe, simply append res with c2.
res = res c2
}
return res
}
# Get specific parameter of logical line.
# Parameter names are case insentitive.
#
# @input str: logical line
# @input param: Parameter name
# @return: Parameter value, comma separated
function getparam(str, param, a, i, paramname, res) {
i = index(str, ";")
if (!i)
return ""
str = substr(str, i + 1)
str = substr(str, 1, index(str, ":") - 1)
split(str, a, ";")
for (i in a) {
paramname = substr(a[i], 1, index(a[i], "=") - 1)
if (toupper(paramname) == toupper(param))
res = res "," substr(a[i], index(a[i], "=") + 1)
}
res = substr(res, 2)
return res
}
# Isolate content part of an iCalendar line, and unescape.
#
# @input str: String
# @return: Escaped content part
function getcontent(str) {
return substr(str, index(str, ":") + 1)
}

184
src/main.sh Normal file
View File

@ -0,0 +1,184 @@
#!/bin/sh
set -eu
# Always load functions
# Helper functions
. "sh/helper.sh"
# iCalendar routines
. "sh/icalendar.sh"
# Attachment handling
. "sh/attachment.sh"
# Categories handling
. "sh/categories.sh"
# Load environment variables only when not yet loaded
if [ ! "${SCRIPT_LOADED:-}" ]; then
# Read configuration
. "sh/config.sh"
# Read theme
. "sh/theme.sh"
# Load awk scripts
. "sh/awkscripts.sh"
# Mark as loaded
export SCRIPT_LOADED=1
fi
__lines() {
find "$ROOT" -mindepth 2 -maxdepth 2 -type f -name '*.vcf' -print0 | xargs -0 -P 0 \
awk \
-v collection_labels="$COLLECTION_LABELS" \
-v nickname_format="$FORMAT_NICKNAME" \
-v fn_format="$FORMAT_FN" \
-v group_label="$FLAG_GROUP" \
-v org_label="$FLAG_ORGANIZATION" \
-v location_label="$FLAG_LOCATION" \
-v individual_label="$FLAG_INDIVIDUAL" \
"$AWK_LIST" |
sort -g
}
# Program starts here
if [ "${1:-}" = "--help" ]; then
bn="$(basename "$0")"
shift
echo "Usage: $bn [OPTION] [FILTER]...
[OPTION]
--help Show this help and exit
Git Integration:
--git-init Activate git usage and exit
--git <cmd> Run git command and exit
Interactive Mode:
--new [FILTER..] Create new entry interactively and start
[FILTER..] Start with the specified filter
Non-Interactive Mode:
--list [FILTER..] List entries and exit
--import <file> Import vCard file and exit
--collection <nr> Select collection to which to import vCard
file. The argument <nr> is the ordinal
describing the collection. It defaults to the
starting value of 1.
[FILTER]
You may specify any of these filters. Filters can be negated using the
--no-... versions, e.g., --no-individuals. Multiple filters are applied in
conjuction.
--individuals Show individuals only
--groups Show groups only
--organizations Show organizations only
--locations Show locations only
--filter <query> Specify custom query
Examples:
$bn --git log
$bn --new
$bn --individuals
$bn --no-locations --filter \"Athens\"
$bn --list --locations
$bn --import ./contact.vcf
$bn --import ./boss.vcf --collection 2
"
exit
fi
# Command line arguments: Interal use
. "sh/cliinternal.sh"
# Command line arguments
. "sh/cli.sh"
# Parse command-line filter (if any)
. "sh/filter.sh"
if [ -n "${list_option:-}" ]; then
__lines |
$FZF \
--filter="$query" \
--delimiter="\t" \
--no-sort \
--with-nth='{2} {3} {1}'
exit 0
fi
# Set terminal title
if [ "$SET_TERMINAL_TITLE" = "yes" ]; then
printf '\033]0;%s\007' "$TERMINAL_TITLE"
fi
while true; do
query=$(stripws "$query")
query="${query:+"$query "}"
selection=$(
__lines | $FZF \
--ansi \
--query="$query" \
--delimiter="\t" \
--no-sort \
--no-hscroll \
--reverse \
--with-nth='{2} {3} {1}' \
--print-query \
--accept-nth=4 \
--preview="$0 --preview {4}" \
--expect="ctrl-n,ctrl-alt-d,alt-v,ctrl-a,ctrl-t" \
--bind="ctrl-r:reload($0 --reload)" \
--bind="alt-0:change-query()" \
--bind="alt-1:change-query(${COLLECTION1:-} )" \
--bind="alt-2:change-query(${COLLECTION2:-} )" \
--bind="alt-3:change-query(${COLLECTION3:-} )" \
--bind="alt-4:change-query(${COLLECTION4:-} )" \
--bind="alt-5:change-query(${COLLECTION5:-} )" \
--bind="alt-6:change-query(${COLLECTION6:-} )" \
--bind="alt-7:change-query(${COLLECTION7:-} )" \
--bind="alt-8:change-query(${COLLECTION8:-} )" \
--bind="alt-9:change-query(${COLLECTION9:-} )" \
--bind="alt-i:change-query($FLAG_INDIVIDUAL )" \
--bind="alt-g:change-query($FLAG_GROUP )" \
--bind="alt-o:change-query($FLAG_ORGANIZATION )" \
--bind="alt-l:change-query($FLAG_LOCATION )" \
--bind="alt-w:toggle-preview-wrap" \
--bind="ctrl-d:preview-half-page-down" \
--bind="ctrl-u:preview-half-page-up" \
--bind="ctrl-s:execute($SYNC_CMD; [ -n \"${GIT:-}\" ] && ${GIT:-echo} add -A && ${GIT:-echo} commit -am 'Synchronized'; printf 'Press <enter> to continue.'; read -r tmp)" || #--color='preview-bg:#ecc297,preview-fg:black' \
true
)
# Line 1: query
# Line 2: key ("" for enter)
# Line 3: relative file path
lines=$(echo "$selection" | wc -l)
if [ "$lines" -eq 1 ]; then
exit 0
fi
query=$(echo "$selection" | head -n 1)
key=$(echo "$selection" | head -n 2 | tail -n 1)
fname=$(echo "$selection" | head -n 3 | tail -n 1)
file="$ROOT/$fname"
case "$key" in
"ctrl-n")
__new
;;
"ctrl-alt-d")
__delete "$file"
;;
"alt-v")
$EDITOR "$file"
;;
"ctrl-a")
__attachment_view "$file"
;;
"ctrl-t")
cat="$(__select_category)"
[ -n "$cat" ] && query="'$cat'"
;;
"")
__edit "$file"
;;
esac
done

181
src/sh/attachment.sh Normal file
View File

@ -0,0 +1,181 @@
# Add attachment to iCalendar file
#
# @input $1: Path to iCalendar file
__add_attachment() {
file="$1"
shift
sel=$(
$FZF \
--ansi \
--prompt="Select attachment> " \
--walker="file,hidden" \
--walker-root="$HOME" \
--expect="ctrl-c,ctrl-g,ctrl-q,esc"
)
key=$(echo "$sel" | head -1)
f=$(echo "$sel" | tail -1)
if [ -n "$key" ]; then
f=""
fi
if [ -z "$f" ] || [ ! -f "$f" ]; then
return
fi
filename=$(basename "$f")
mime=$(file -b -i "$f" | cut -d ';' -f 1)
if [ -z "$mime" ]; then
mime="application/octet-stream"
fi
fenc=$(mktemp)
base64 "$f" >"$fenc"
filetmp=$(mktemp)
awk -v file="$fenc" -v mime="$mime" -v filename="$filename" "$AWK_ATTACH" "$file" >"$filetmp"
mv "$filetmp" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "Added attachment" -- "$file"
fi
rm "$fenc"
}
# Open attachment from iCalendar file
#
# @input $1: Attachment id
# @input $2: Attachment name
# @input $3: Attachment format
# @input $4: Attachment encoding
# @input $5: Path to iCalendar file
__open_attachment() {
attid="$1"
shift
attname="$1"
shift
attfmt="$1"
shift
attenc="$1"
shift
file="$1"
shift
if [ "$attenc" != "base64" ]; then
err "Unsupported attachment encoding: $attenc. Press <enter> to continue."
read -r tmp
return
fi
if [ -n "$attname" ]; then
tmpdir=$(mktemp -d)
attpath="$tmpdir/$attname"
elif [ -n "$attfmt" ]; then
attext=$(echo "$attfmt" | cut -d "/" -f 2)
attpath=$(mktemp --suffix="$attext")
else
attpath=$(mktemp)
fi
# Get file and decode
awk -v id="$attid" "$AWK_ATTACHDD" "$file" | base64 -d >"$attpath"
fn=$(file "$attpath")
while true; do
printf "Are you sure you want to open \"%s\"? (yes/no): " "$fn" >/dev/tty
read -r yn
case $yn in
"yes")
$OPEN "$attpath"
printf "Press <enter> to continue." >/dev/tty
read -r tmp
break
;;
"no")
break
;;
*)
echo "Please answer \"yes\" or \"no\"." >/dev/tty
;;
esac
done
# Clean up
rm -f "$attpath"
if [ -n "${tmpdir:-}" ] && [ -d "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
fi
}
# Delete attachment from iCalendar file
#
# @input $1: Attachment id
# @input $2: Path to iCalendar File
__del_attachment() {
attid="$1"
shift
file="$1"
shift
while true; do
printf "Are you sure you want to delete attachment \"%s\"? (yes/no): " "$attid" >/dev/tty
read -r yn
case $yn in
"yes")
filetmp=$(mktemp)
awk -v id="$attid" "$AWK_ATTACHRM" "$file" >"$filetmp"
mv "$filetmp" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "Deleted attachment" -- "$file"
fi
break
;;
"no")
break
;;
*)
echo "Please answer \"yes\" or \"no\"." >/dev/tty
;;
esac
done
}
# Show attachment window
#
# @input $1: Path to iCalendar file
__attachment_view() {
file="$1"
shift
att=$(
awk "$AWK_ATTACHLS" "$file" |
$FZF \
--ansi \
--delimiter="\t" \
--accept-nth=1,2,3,4 \
--with-nth="Attachment {1}: \"{2}\" {3} ({5})" \
--no-sort \
--tac \
--margin="30%,30%" \
--border=bold \
--border-label="Attachment View Keys: <enter> open, <ctrl-alt-d> delete, <ctrl-a> add" \
--expect="ctrl-a" \
--expect="ctrl-c,ctrl-g,ctrl-q,ctrl-d,esc,q,backspace" \
--print-query \
--bind="start:hide-input" \
--bind="ctrl-alt-d:show-input+change-query(ctrl-alt-d)+accept" \
--bind='load:transform:[ "$FZF_TOTAL_COUNT" -eq 0 ] && echo "unbind(enter)+unbind(ctrl-alt-d)"' \
--bind="w:toggle-wrap" \
--bind="j:down" \
--bind="k:up" ||
true
)
key=$(echo "$att" | head -2 | xargs)
sel=$(echo "$att" | tail -1)
attid=$(echo "$sel" | cut -f 1)
attname=$(echo "$sel" | cut -f 2)
attfmt=$(echo "$sel" | cut -f 3)
attenc=$(echo "$sel" | cut -f 4)
case "$key" in
"ctrl-c" | "ctrl-g" | "ctrl-q" | "ctrl-d" | "esc" | "q" | "backspace") ;;
"ctrl-alt-d")
__del_attachment "$attid" "$file"
;;
"ctrl-a")
__add_attachment "$file"
;;
*)
__open_attachment "$attid" "$attname" "$attfmt" "$attenc" "$file"
;;
esac
#
}

69
src/sh/awkscripts.sh Normal file
View File

@ -0,0 +1,69 @@
AWK_LIST=$(
cat <<'EOF'
@@include awk/list.awk
EOF
)
export AWK_LIST
AWK_PREVIEW=$(
cat <<'EOF'
@@include awk/preview.awk
EOF
)
export AWK_PREVIEW
AWK_ALTERTODO=$(
cat <<'EOF'
@@include awk/altertodo.awk
EOF
)
export AWK_ALTERTODO
AWK_GET=$(
cat <<'EOF'
@@include awk/get.awk
EOF
)
export AWK_GET
AWK_NEW=$(
cat <<'EOF'
@@include awk/new.awk
EOF
)
export AWK_NEW
AWK_UPDATE=$(
cat <<'EOF'
@@include awk/update.awk
EOF
)
export AWK_UPDATE
AWK_ATTACH=$(
cat <<'EOF'
@@include awk/attach.awk
EOF
)
export AWK_ATTACH
AWK_ATTACHDD=$(
cat <<'EOF'
@@include awk/attachdd.awk
EOF
)
export AWK_ATTACHDD
AWK_ATTACHLS=$(
cat <<'EOF'
@@include awk/attachls.awk
EOF
)
export AWK_ATTACHLS
AWK_ATTACHRM=$(
cat <<'EOF'
@@include awk/attachrm.awk
EOF
)
export AWK_ATTACHRM

19
src/sh/categories.sh Normal file
View File

@ -0,0 +1,19 @@
# List all categories and lest user select
__select_category() {
find "$ROOT" -type f -name "*.ics" -print0 |
xargs -0 -P 0 \
awk -v field="CATEGORIES" -v format="csv" "$AWK_GET" |
tr ',' '\n' |
sort |
uniq |
grep '.' |
$FZF \
--ansi \
--prompt="Select category> " \
--no-sort \
--tac \
--margin="30%,30%" \
--border=bold \
--border-label="Categories" ||
true
}

122
src/sh/cli.sh Normal file
View File

@ -0,0 +1,122 @@
case "${1:-}" in
"--git-init")
shift
if [ -n "${GIT:-}" ]; then
err "Git already enabled"
return 1
fi
if ! command -v "git" >/dev/null; then
err "Git not installed"
return 1
fi
git -C "$ROOT" init
git -C "$ROOT" add -A
git -C "$ROOT" commit -m 'Initial commit: Start git tracking'
exit
;;
"--git")
shift
if [ -z "${GIT:-}" ]; then
err "Git not supported, run \`$0 --git-init\` first"
return 1
fi
$GIT "$@"
exit
;;
"--new")
shift
__new
export next_filter=1
;;
"--list")
shift
export next_filter=1
export list_option=1
;;
esac
if [ -z "${next_filter:-}" ]; then
# else [FILTER] are the next options
# Here, we have --add-xyz with --collection or nothign
case "${1:-}" in
"--add-note" | "--add-task" | "--add-jour" | "--collection")
noninteractive=1
;;
esac
if [ -n "${noninteractive:-}" ]; then
while [ -n "${1:-}" ]; do
case "$1" in
"--add-note" | "--add-task" | "--add-jour")
if [ -n "${add_option:-}" ]; then
err "What do you want to add?"
exit 1
fi
add_option="$1"
shift
summary=${1-}
if [ -z "$summary" ]; then
err "You did not give a summary"
exit 1
fi
shift
if [ "$add_option" = "--add-task" ] && [ -n "${1:-}" ]; then
case "$1" in
"--"*)
continue
;;
*)
due=$(printf "%s" "$1" | tr -dc "[:alnum:][:blank:]")
shift
if [ -z "$due" ] || ! date -d "$due" >/dev/null 2>&1; then
err "Invalid due date"
exit 1
fi
;;
esac
fi
;;
"--collection")
shift
collection="$(printf "%s" "$COLLECTION_LABELS" |
cut -d ";" -f "${1:-}" 2>/dev/null |
cut -d "=" -f 1 2>/dev/null)"
if [ -z "$collection" ]; then
err "Invalid collection"
exit 1
fi
shift
;;
*)
err "Unknown non-interactive option: $1"
exit 1
;;
esac
done
fi
fi
if [ -n "${noninteractive:-}" ]; then
if [ -z "${add_option:-}" ]; then
err "Specified collection, but nothing to add"
exit 1
fi
if [ -z "${collection:-}" ]; then
collection="$(
printf "%s" "$COLLECTION_LABELS" |
cut -d ";" -f 1 |
cut -d "=" -f 1
)"
fi
case "$add_option" in
"--add-note")
__add_note "$collection" "$summary"
;;
"--add-task")
__add_task "$collection" "$summary" "${due:-}"
;;
"--add-jour")
__add_jour "$collection" "$summary"
;;
esac
exit 0
fi

29
src/sh/cliinternal.sh Normal file
View File

@ -0,0 +1,29 @@
# Command-line interface for internal use only
# Generate preview of file from selection
if [ "${1:-}" = "--preview" ]; then
shift
name="$1"
shift
file="$ROOT/$name"
awk \
-v label_tel_text="$LABEL_TEL_TEXT" \
-v label_tel_fax="$LABEL_TEL_FAX" \
-v label_tel_cell="$LABEL_TEL_CELL" \
-v label_tel_video="$LABEL_TEL_VIDEO" \
-v label_tel_pager="$LABEL_TEL_PAGER" \
-v label_tel_textphone="$LABEL_TEL_TEXTPHONE" \
-v label_tel="$LABEL_TEL" \
-v label_work="$LABEL_WORK" \
-v label_home="$LABEL_HOME" \
"$AWK_PREVIEW" "$file" |
$CAT
exit
fi
# Reload view
if [ "${1:-}" = "--reload" ]; then
shift
__lines
exit
fi

68
src/sh/config.sh Normal file
View File

@ -0,0 +1,68 @@
CONFIGFILE="${CONFIGFILE:-$HOME/.config/fzf-contact/config}"
export TERMINAL_TITLE="💌 fzf-contact | Address book"
if [ ! -f "$CONFIGFILE" ]; then
err "Configuration '$CONFIGFILE' not found."
exit 1
fi
# shellcheck source=/dev/null
. "$CONFIGFILE"
if [ -z "${ROOT:-}" ] || [ -z "${COLLECTION_LABELS:-}" ]; then
err "Configuration '$CONFIGFILE' is incomplete."
exit 1
fi
if [ ! -d "$ROOT" ]; then
err "Directory '$ROOT' does not exist."
exit 1
fi
SYNC_CMD="${SYNC_CMD:-}"
SET_TERMINAL_TITLE="${SET_TERMINAL_TITLE:-yes}"
export ROOT
export SYNC_CMD
export COLLECTION_LABELS
export SET_TERMINAL_TITLE
for i in $(seq 9); do
collection=$(printf "%s" "$COLLECTION_LABELS" | cut -d ';' -f "$i" | cut -d '=' -f 1)
label=$(printf "%s" "$COLLECTION_LABELS" | cut -d ';' -f "$i" | cut -d '=' -f 2)
if [ -n "$label" ] && [ ! -d "$ROOT/$collection" ]; then
err "Collection directory for '$label' does not exist."
exit 1
fi
if [ -z "$label" ]; then
export COLLECTION_COUNT=$((i - 1))
break
fi
export "COLLECTION$i=$label"
done
# Tools
if command -v "fzf" >/dev/null; then
FZF="fzf"
else
err "Did not find the command-line fuzzy finder fzf."
exit 1
fi
export FZF
if command -v "uuidgen" >/dev/null; then
UUIDGEN="uuidgen"
else
err "Did not find the uuidgen command."
exit 1
fi
export UUIDGEN
if command -v "bat" >/dev/null; then
CAT="bat"
elif command -v "batcat" >/dev/null; then
CAT="batcat"
fi
CAT=${CAT:+$CAT --color=always --style=plain --language=md}
CAT=${CAT:-cat}
export CAT
if command -v "git" >/dev/null && [ -d "$ROOT/.git" ]; then
GIT="git -C $ROOT"
export GIT
fi
export OPEN=${OPEN:-open}

53
src/sh/filter.sh Normal file
View File

@ -0,0 +1,53 @@
# Build query
while [ -n "${1:-}" ]; do
case "${1:-}" in
"--individuals")
shift
cliquery="${cliquery:-} $FLAG_INDIVIDUAL"
;;
"--no-individuals")
shift
cliquery="${cliquery:-} !$FLAG_INDIVIDUAL"
;;
"--groups")
shift
cliquery="${cliquery:-} $FLAG_GROUP"
;;
"--no-groups")
shift
cliquery="${cliquery:-} !$FLAG_GROUP"
;;
"--organizations")
shift
cliquery="${cliquery:-} $FLAG_ORGANIZATION"
;;
"--no-organizations")
shift
cliquery="${cliquery:-} !$FLAG_ORGANIZATION"
;;
"--locations")
shift
cliquery="${cliquery:-} $FLAG_LOCATION"
;;
"--no-locations")
shift
cliquery="${cliquery:-} !$FLAG_LOCATION"
;;
"--filter")
shift
cliquery="${cliquery:-} $1"
shift
;;
"--no-filter")
shift
cliquery="${cliquery:-} !$1"
shift
;;
*)
err "Unknown option \"$1\""
exit 1
;;
esac
done
query=${cliquery:-}
export query

9
src/sh/helper.sh Normal file
View File

@ -0,0 +1,9 @@
# Print error message
err() {
echo "$1" >/dev/tty
}
# Strip whitespaces from argument
stripws() {
echo "$@" | sed "s/^ *//" | sed "s/ *$//"
}

264
src/sh/icalendar.sh Normal file
View File

@ -0,0 +1,264 @@
# Interface to modify iCalendar files
# Wrapper to add entry from markdown file
#
# @input $1: path to markdown file
# @input $2: collection to add to
__add_from_md() {
tmpmd="$1"
shift
collection="$1"
shift
file=""
while [ -f "$file" ] || [ -z "$file" ]; do
uuid=$($UUIDGEN)
file="$ROOT/$collection/$uuid.ics"
done
tmpfile="$tmpmd.ics"
if awk -v uid="$uuid" "$AWK_NEW" "$tmpmd" >"$tmpfile"; then
if [ ! -d "$ROOT/$collection" ]; then
mkdir -p "$ROOT/$collection"
fi
mv "$tmpfile" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "File added" -- "$file"
fi
else
rm -f "$tmpfile"
err "Failed to create new entry."
fi
rm "$tmpmd"
}
# Noninteractively add note, and fill description from stdin
#
# @input $1: Collection
# @input $2: Summary
__add_note() {
collection="$1"
shift
summary="$1"
shift
tmpmd=$(mktemp --suffix='.md')
{
echo "# $summary"
echo ""
} >"$tmpmd"
if [ ! -t 0 ]; then
cat /dev/stdin >>"$tmpmd"
fi
__add_from_md "$tmpmd" "$collection"
}
# Noninteractively add task, and fill description from stdin
#
# @input $1: Collection
# @input $2: Summary
# @input $3: Due date (optional)
__add_task() {
collection="$1"
shift
summary="$1"
shift
due="${1:-}"
tmpmd=$(mktemp --suffix='.md')
{
echo "::: <| $due"
echo "# $summary"
echo ""
} >"$tmpmd"
if [ ! -t 0 ]; then
cat /dev/stdin >>"$tmpmd"
fi
__add_from_md "$tmpmd" "$collection"
}
# Noninteractively add jounral, and fill description from stdin
#
# @input $1: Collection
# @input $2: Summary
__add_jour() {
collection="$1"
shift
summary="$1"
shift
tmpmd=$(mktemp --suffix='.md')
{
echo "::: |> <!-- keep this line to associate the entry to _today_ -->"
echo "# $summary"
echo ""
} >"$tmpmd"
if [ ! -t 0 ]; then
cat /dev/stdin >>"$tmpmd"
fi
__add_from_md "$tmpmd" "$collection"
}
# Toggle completed status of VTODO
#
# @input $1: Relative path to iCalendar file
__toggle_completed() {
fname="$1"
shift
file="$ROOT/$fname"
tmpfile=$(mktemp)
awk "$AWK_ALTERTODO" "$file" >"$tmpfile"
mv "$tmpfile" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "Completed toggle" -- "$file"
fi
}
# Change priority of VTODO entry
#
# @input $1: Delta, can be any integer
# @input $2: Relative path to iCalendar file
__change_priority() {
delta=$1
shift
fname="$1"
shift
file="$ROOT/$fname"
tmpfile=$(mktemp)
awk -v delta="$delta" "$AWK_ALTERTODO" "$file" >"$tmpfile"
mv "$tmpfile" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "Priority changed by $delta" -- "$file"
fi
}
# Edit file
#
# @input $1: File path
__edit() {
file="$1"
shift
tmpmd=$(mktemp --suffix='.md')
due=$(awk -v field="DUE" -v format="date" "$AWK_GET" "$file")
if [ -n "$due" ]; then
echo "::: <| $due" >"$tmpmd"
fi
{
echo "# $(awk -v field="SUMMARY" -v oneline=1 "$AWK_GET" "$file")"
echo "> $(awk -v field="CATEGORIES" -v format="csv" -v oneline=1 "$AWK_GET" "$file")"
echo ""
awk -v field="DESCRIPTION" "$AWK_GET" "$file"
} >>"$tmpmd"
checksum=$(cksum "$tmpmd")
# Open in editor
$EDITOR "$tmpmd" >/dev/tty
# Update only if changes are detected
while [ "$checksum" != "$(cksum "$tmpmd")" ]; do
tmpfile="$tmpmd.ics"
if awk "$AWK_UPDATE" "$tmpmd" "$file" >"$tmpfile"; then
mv "$tmpfile" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "File modified" -- "$file"
fi
break
else
rm -f "$tmpfile"
err "Failed to update entry. Press <enter> to continue."
read -r tmp
# Re-open in editor
$EDITOR "$tmpmd" >/dev/tty
fi
done
rm "$tmpmd"
}
# Delete file
#
# @input $1: File path
__delete() {
file="$1"
shift
summary=$(awk -v field="SUMMARY" "$AWK_GET" "$file")
while true; do
printf "Do you want to delete the entry with the title \"%s\"? (yes/no): " "$summary" >/dev/tty
read -r yn
case $yn in
"yes")
rm -v "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "File deleted" -- "$file"
fi
break
;;
"no")
break
;;
*)
echo "Please answer \"yes\" or \"no\"." >/dev/tty
;;
esac
done
}
# Add file
__new() {
collection=$(printf "%s" "$COLLECTION_LABELS" |
tr ';' '\n' |
$FZF \
--ansi \
--prompt="Choose collection> " \
--select-1 \
--no-sort \
--tac \
--margin="30%,30%" \
--delimiter='=' \
--border=bold \
--border-label="Collections" \
--with-nth=2 \
--accept-nth=1 || true)
if [ -z "$collection" ]; then
return
fi
file=""
while [ -f "$file" ] || [ -z "$file" ]; do
uuid=$($UUIDGEN)
file="$ROOT/$collection/$uuid.ics"
done
tmpmd=$(mktemp --suffix='.md')
{
echo "::: |> <!-- keep this line to associate the entry to _today_ -->"
echo "::: <| <!-- specify the due date for to-dos, can be empty, a date string, or even \"next Sunday\" -->"
echo "# <!-- write summary here -->"
echo "> <!-- comma-separated list of categories -->"
echo ""
} >"$tmpmd"
checksum=$(cksum "$tmpmd")
# Open in editor
$EDITOR "$tmpmd" >/dev/tty
# Update if changes are detected
while [ "$checksum" != "$(cksum "$tmpmd")" ]; do
tmpfile="$tmpmd.ics"
if awk -v uid="$uuid" "$AWK_NEW" "$tmpmd" >"$tmpfile"; then
if [ ! -d "$ROOT/$collection" ]; then
mkdir -p "$ROOT/$collection"
fi
mv "$tmpfile" "$file"
if [ -n "${GIT:-}" ]; then
$GIT add "$file"
$GIT commit -q -m "File added" -- "$file"
fi
break
else
rm -f "$tmpfile"
err "Failed to create new entry. Press <enter> to continue."
read -r tmp
# Re-open in editor
$EDITOR "$tmpmd" >/dev/tty
fi
done
rm "$tmpmd"
}

28
src/sh/theme.sh Normal file
View File

@ -0,0 +1,28 @@
# Colors
GREEN="\033[1;32m"
RED="\033[1;31m"
WHITE="\033[1;97m"
CYAN="\033[1;36m"
FAINT="\033[2m"
OFF="\033[m"
# Flags
export FLAG_GROUP="${FLAG_GROUP:-👥}"
export FLAG_ORGANIZATION="${FLAG_ORGANIZATION:-🏛️}"
export FLAG_LOCATION="${FLAG_LOCATION:-🗺️}"
export FLAG_INDIVIDUAL="${FLAG_INDIVIDUAL:-👤}"
# Style
export FORMAT_NICKNAME="${FORMAT_NICKNAME:-${GREEN}<<nickname>>${OFF} ${WHITE}${FAINT}(<<fn>>)${OFF}}"
export FORMAT_FN="${FORMAT_FN:-${GREEN}<<fn>>${OFF}}"
# Preview labels
export LABEL_HOME="${LABEL_HOME:-🏠}"
export LABEL_WORK="${LABEL_WORK:-🏢}"
export LABEL_TEL="${LABEL_TEL:-📞}"
export LABEL_TEL_TEXT="${LABEL_TEL_TEXT:-💬}"
export LABEL_TEL_FAX="${LABEL_TEL_FAX:-📠}"
export LABEL_TEL_CELL="${LABEL_TEL_CELL:-📱}"
export LABEL_TEL_VIDEO="${LABEL_TEL_VIDEO:-📹}"
export LABEL_TEL_PAGER="${LABEL_TEL_PAGER:-📟}"
export LABEL_TEL_TEXTPHONE="${LABEL_TEL_TEXTPHONE:-🦻}"