From 4ba328934b7fc32974ceb45ebbf33ffacfb492d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=84min=20Baumeler?= Date: Tue, 15 Jul 2025 13:41:20 +0200 Subject: [PATCH] initial commit: readonly --- .gitignore | 1 + LICENSE | 7 + README.md | 5 + scripts/build.sh | 19 +++ src/awk/altertodo.awk | 41 +++++ src/awk/attach.awk | 35 +++++ src/awk/attachdd.awk | 8 + src/awk/attachls.awk | 41 +++++ src/awk/attachrm.awk | 13 ++ src/awk/get.awk | 49 ++++++ src/awk/list.awk | 85 +++++++++++ src/awk/new.awk | 69 +++++++++ src/awk/preview.awk | 341 ++++++++++++++++++++++++++++++++++++++++++ src/awk/update.awk | 57 +++++++ src/lib/awk/vcard.awk | 159 ++++++++++++++++++++ src/main.sh | 184 +++++++++++++++++++++++ src/sh/attachment.sh | 181 ++++++++++++++++++++++ src/sh/awkscripts.sh | 69 +++++++++ src/sh/categories.sh | 19 +++ src/sh/cli.sh | 122 +++++++++++++++ src/sh/cliinternal.sh | 29 ++++ src/sh/config.sh | 68 +++++++++ src/sh/filter.sh | 53 +++++++ src/sh/helper.sh | 9 ++ src/sh/icalendar.sh | 264 ++++++++++++++++++++++++++++++++ src/sh/theme.sh | 28 ++++ 26 files changed, 1956 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 scripts/build.sh create mode 100644 src/awk/altertodo.awk create mode 100644 src/awk/attach.awk create mode 100644 src/awk/attachdd.awk create mode 100644 src/awk/attachls.awk create mode 100644 src/awk/attachrm.awk create mode 100644 src/awk/get.awk create mode 100644 src/awk/list.awk create mode 100644 src/awk/new.awk create mode 100644 src/awk/preview.awk create mode 100644 src/awk/update.awk create mode 100644 src/lib/awk/vcard.awk create mode 100644 src/main.sh create mode 100644 src/sh/attachment.sh create mode 100644 src/sh/awkscripts.sh create mode 100644 src/sh/categories.sh create mode 100644 src/sh/cli.sh create mode 100644 src/sh/cliinternal.sh create mode 100644 src/sh/config.sh create mode 100644 src/sh/filter.sh create mode 100644 src/sh/helper.sh create mode 100644 src/sh/icalendar.sh create mode 100644 src/sh/theme.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c8dd8a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +fzf-contact diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..da8bc19 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfdeba7 --- /dev/null +++ b/README.md @@ -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). diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..22871e0 --- /dev/null +++ b/scripts/build.sh @@ -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}" diff --git a/src/awk/altertodo.awk b/src/awk/altertodo.awk new file mode 100644 index 0000000..ebb797d --- /dev/null +++ b/src/awk/altertodo.awk @@ -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 } diff --git a/src/awk/attach.awk b/src/awk/attach.awk new file mode 100644 index 0000000..410bb3e --- /dev/null +++ b/src/awk/attach.awk @@ -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 = 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 } diff --git a/src/awk/attachdd.awk b/src/awk/attachdd.awk new file mode 100644 index 0000000..f6fc79f --- /dev/null +++ b/src/awk/attachdd.awk @@ -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 } diff --git a/src/awk/attachls.awk b/src/awk/attachls.awk new file mode 100644 index 0000000..d1e5b19 --- /dev/null +++ b/src/awk/attachls.awk @@ -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 } diff --git a/src/awk/attachrm.awk b/src/awk/attachrm.awk new file mode 100644 index 0000000..2a8659e --- /dev/null +++ b/src/awk/attachrm.awk @@ -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 } diff --git a/src/awk/get.awk b/src/awk/get.awk new file mode 100644 index 0000000..a94864c --- /dev/null +++ b/src/awk/get.awk @@ -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 + } + } +} diff --git a/src/awk/list.awk b/src/awk/list.awk new file mode 100644 index 0000000..6ff32a2 --- /dev/null +++ b/src/awk/list.awk @@ -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 "<>", then the behaviour is unexpected + gsub("<>", nickname, line) + gsub("<>", fn, line) + + print line, + collection, + kind, + fpath +} diff --git a/src/awk/new.awk b/src/awk/new.awk new file mode 100644 index 0000000..7ee2d67 --- /dev/null +++ b/src/awk/new.awk @@ -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" +} diff --git a/src/awk/preview.awk b/src/awk/preview.awk new file mode 100644 index 0000000..cbacb3e --- /dev/null +++ b/src/awk/preview.awk @@ -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 + } +} diff --git a/src/awk/update.awk b/src/awk/update.awk new file mode 100644 index 0000000..a3b4fe8 --- /dev/null +++ b/src/awk/update.awk @@ -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 } diff --git a/src/lib/awk/vcard.awk b/src/lib/awk/vcard.awk new file mode 100644 index 0000000..18d9804 --- /dev/null +++ b/src/lib/awk/vcard.awk @@ -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) +} diff --git a/src/main.sh b/src/main.sh new file mode 100644 index 0000000..62b7832 --- /dev/null +++ b/src/main.sh @@ -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 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 Import vCard file and exit + --collection Select collection to which to import vCard + file. The argument 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 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 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 diff --git a/src/sh/attachment.sh b/src/sh/attachment.sh new file mode 100644 index 0000000..383677f --- /dev/null +++ b/src/sh/attachment.sh @@ -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 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 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: open, delete, 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 + # +} diff --git a/src/sh/awkscripts.sh b/src/sh/awkscripts.sh new file mode 100644 index 0000000..18885a9 --- /dev/null +++ b/src/sh/awkscripts.sh @@ -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 diff --git a/src/sh/categories.sh b/src/sh/categories.sh new file mode 100644 index 0000000..1035435 --- /dev/null +++ b/src/sh/categories.sh @@ -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 +} diff --git a/src/sh/cli.sh b/src/sh/cli.sh new file mode 100644 index 0000000..f7737e5 --- /dev/null +++ b/src/sh/cli.sh @@ -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 diff --git a/src/sh/cliinternal.sh b/src/sh/cliinternal.sh new file mode 100644 index 0000000..ae93bf4 --- /dev/null +++ b/src/sh/cliinternal.sh @@ -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 diff --git a/src/sh/config.sh b/src/sh/config.sh new file mode 100644 index 0000000..cb6a2de --- /dev/null +++ b/src/sh/config.sh @@ -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} diff --git a/src/sh/filter.sh b/src/sh/filter.sh new file mode 100644 index 0000000..1f6e635 --- /dev/null +++ b/src/sh/filter.sh @@ -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 diff --git a/src/sh/helper.sh b/src/sh/helper.sh new file mode 100644 index 0000000..bcaf77b --- /dev/null +++ b/src/sh/helper.sh @@ -0,0 +1,9 @@ +# Print error message +err() { + echo "❌ $1" >/dev/tty +} + +# Strip whitespaces from argument +stripws() { + echo "$@" | sed "s/^ *//" | sed "s/ *$//" +} diff --git a/src/sh/icalendar.sh b/src/sh/icalendar.sh new file mode 100644 index 0000000..f8e8db2 --- /dev/null +++ b/src/sh/icalendar.sh @@ -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 "::: |> " + 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 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 "::: |> " + echo "::: <| " + echo "# " + echo "> " + 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 to continue." + read -r tmp + # Re-open in editor + $EDITOR "$tmpmd" >/dev/tty + fi + done + rm "$tmpmd" +} diff --git a/src/sh/theme.sh b/src/sh/theme.sh new file mode 100644 index 0000000..357482b --- /dev/null +++ b/src/sh/theme.sh @@ -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}<>${OFF} ${WHITE}${FAINT}(<>)${OFF}}" +export FORMAT_FN="${FORMAT_FN:-${GREEN}<>${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:-🦻}"