From: Paul Hänsch Date: Sat, 7 Sep 2024 11:10:52 +0000 (+0200) Subject: process split fields as arrays X-Git-Url: https://git.plutz.net/?p=confetti;a=commitdiff_plain;h=refs%2Fheads%2Fmaster;hp=6e7496f7d5f9d5804ff071a53da789ca5ef9d4ba process split fields as arrays --- diff --git a/cards/l10n.sh b/cards/l10n.sh index 2d9dc06..fde98e0 100755 --- a/cards/l10n.sh +++ b/cards/l10n.sh @@ -37,6 +37,7 @@ l10n(){ X-ZACK-LEAVEDATE) printf %s "Abmelde­datum";; X-ZACK-JOINDATE_short) printf %s "Anm.";; X-ZACK-LEAVEDATE_short) printf %s "Abm.";; + X-IBAN) printf %s "IBAN";; *) l10n_global "$word";; esac diff --git a/cards/list.sh b/cards/list.sh index 519005c..e12d760 100755 --- a/cards/list.sh +++ b/cards/list.sh @@ -31,7 +31,10 @@ edit_card(){ [ $(pdi_count "$card" IMPP) -gt 0 ] && edit_item "$card" IMPP [ $(pdi_count "$card" URL ) -gt 0 ] && edit_item "$card" URL )] - [div .section .address $(edit_item "$card" ADR)] + [div .section .address $( + edit_item "$card" ADR + [ $(pdi_count "$card" X-IBAN) -gt 0 ] && edit_item "$card" X-IBAN + )] [div .section .note $(edit_item "$card" NOTE)] [div .section .attendance [h3 $(l10n course_attendance) ] [div .attendance $( @@ -65,7 +68,7 @@ edit_card(){ [div .item .newfield [select name="newfield" [option value="" disabled="disabled" selected="selected" $(l10n edit_addfieldtext)] - $(for f in NICKNAME EMAIL TEL IMPP ADR URL NOTE X-ZACK-LEAVEDATE; do + $(for f in NICKNAME EMAIL TEL IMPP ADR URL NOTE X-ZACK-LEAVEDATE X-IBAN; do printf '[option value="%s" %s] ' "$f" "$(l10n "$f")" done) ][button type="submit" name="action" value="addfield" $(l10n edit_addfield)] @@ -90,7 +93,7 @@ print_card(){ )] [div .section .phone . $(card_item "$card" TEL)] [div .section .message . $(card_item "$card" EMAIL IMPP URL)] - [div .section .address . $(card_item "$card" ADR)] + [div .section .address . $(card_item "$card" ADR X-IBAN)] [div .section .note . $(card_item "$card" NOTE)] [div .section .attendance [h3 $(l10n course_attendance) ] [ul $(grep -F " ${cardfile##*/}" "$_DATA/mappings/attendance" |while read each discard; do @@ -101,6 +104,7 @@ print_card(){ $(card_item "$card" CATEGORIES) ] [div .control + [a .button .item href="${_BASE}/ledgers/account.sh?card=${cardfile##*/}" $(l10n ledger)] [a .button .item href="${_BASE}/cards/edit_card.sh?card=${cardfile##*/}" $(l10n edit)] [a .button .item href="${_BASE}/cards/export_card.sh?card=${cardfile##*/}" $(l10n vcf_export)] ] diff --git a/cards/update_card.sh b/cards/update_card.sh index 2b87632..d57f503 100755 --- a/cards/update_card.sh +++ b/cards/update_card.sh @@ -35,7 +35,7 @@ attfile="$_DATA/mappings/attendance" action="$(POST action)" newfield="$(POST newfield |grep -m 1 -xE '[A-Z][A-Z0-9-]*')" -if printf '%s\n' "$action" |grep -qxE 'addfield [A-Z][A-Z0-9]*'; then +if printf '%s\n' "$action" |grep -qxE 'addfield [A-Z][A-Z0-9-]*'; then newfield="${action##* }" action=addfield fi diff --git a/cgilite/cgilite.awk b/cgilite/cgilite.awk index f16ed6a..ebf4411 100644 --- a/cgilite/cgilite.awk +++ b/cgilite/cgilite.awk @@ -1,5 +1,7 @@ #!/bin/env awk -f +function debug(t) { printf "%s\n", t >>"/dev/stderr"; } + function PATH( str, seg, out ) { while ( str ) { seg = str; @@ -151,8 +153,9 @@ BEGIN { split("", _GET); split("", _POST); split("", _REF); split("", _HEADER); split("", _COOKIE); - if ( ENVIRON["REQUEST_METHOD"] ) + if ( ENVIRON["REQUEST_METHOD"] ) { _cgilite_headers(); - else + } else { _cgilite_request(); + } } diff --git a/cgilite/common.css b/cgilite/common.css index 30c3942..16e99f2 100644 --- a/cgilite/common.css +++ b/cgilite/common.css @@ -31,7 +31,7 @@ a { color: #068; word-break: break-word; } -a.button { +a.button, label.button { font-style: inherit; text-decoration: inherit; color: inherit; @@ -86,7 +86,7 @@ h1 { } h2 { font-size: 1.125em; } -select, input, button, textarea, a.button { +select, input, button, textarea, a.button, label.button { display: inline-block; color: #000; background-color: #FFF; border: .5pt solid; @@ -103,7 +103,7 @@ input[type=radio], input[type=checkbox] { } input[type=number] { text-align: right; padding-right: 0; } -button, input[type=button], a.button { +button, input[type=button], a.button, label.button { box-shadow: .125em .125em .25em; cursor: pointer; } diff --git a/cgilite/storage.sh b/cgilite/storage.sh index 17ea0d0..5c61df0 100755 --- a/cgilite/storage.sh +++ b/cgilite/storage.sh @@ -94,6 +94,21 @@ UNSTRING(){ printf '%s\n' "$out" } +RXLITERAL(){ + # sed -E 's;[].*+?^${}()|\[];\\&;g' + local in out='' + [ $# -gt 0 ] && in="$*" || in="$(cat)" + while [ "$in" ]; do case $in in + [.+^\$\{\}\(\)\[\]\*\?\|\\]*) + out="${out}\\${in%"${in#?}"}"; in="${in#?}"; + ;; + *)out="${out}${in%%[.+^\$\{\}\(\)\[\]\*\?\|\\]*}" + in="${in#"${in%%[.+^\$\{\}\(\)\[\]\*\?\|\\]*}"}" + ;; + esac; done + printf '%s\n' "$out" +} + DBM() { local file="$1" cmd="$2" local k v key value diff --git a/courses/export_pdf.sh b/courses/export_pdf.sh index 92e10c5..10230b8 100755 --- a/courses/export_pdf.sh +++ b/courses/export_pdf.sh @@ -90,9 +90,10 @@ style_td='style="border: 1pt solid; padding: 1mm 2mm; vertical-align: top;"' ] ][body lang="de_DE" [table width="100%" style="page-break-after: always;" - [col width=10*] [col width=5*] [col width=10*] [col width=15*] + [col width=2*] [col width=10*] [col width=5*] [col width=10*] [col width=15*] [thead - [tr [th $style_td . $(l10n N)] [th $style_td . $(l10n BDAY)] [th $style_td . $(l10n TEL)] [th $style_td . $(l10n NOTE)]] + [tr [th $style_td . $(l10n No.)] [th $style_td . $(l10n N)] [th $style_td . $(l10n BDAY)] + [th $style_td . $(l10n TEL)] [th $style_td . $(l10n NOTE)]] ][tbody $(grep -F "${coursefile##*/} " "$_DATA/mappings/attendance" |while read discard each; do vcf="$(pdi_load "$_DATA/vcard/$each")" @@ -101,12 +102,16 @@ style_td='style="border: 1pt solid; padding: 1mm 2mm; vertical-align: top;"' [ "$type" ] && type="$(l10n "TYPE=$type"):" printf '%s %s
' "$type" "$(pdi_value "$vcf" TEL $n)" done )" - printf '[tr valign=top [td %s .N . %s] [td %s .BDAY . %s] [td %s .TEL . %s] [td %s .NOTE . %s]]\n' \ + printf '[tr valign=top [td %s .No @@No@@] [td %s .N . %s] [td %s .BDAY . %s] [td %s .TEL . %s] [td %s .NOTE . %s]]\n' \ + "${style_td%\"} text-align: right;\"" \ "$style_td" "$(pdi_value "$vcf" FN |unescape |HTML)" \ "$style_td" "$(pdi_value "$vcf" BDAY |unescape |HTML)" \ "$style_td" "$tel" \ "$style_td" "$(pdi_value "$vcf" NOTE |unescape |HTML)" - done |sort)] + done |sort |while read -r line; do + attno=$((${attno-0} + 1)) + printf '%s%2i%s\n' "${line%%@@No@@*}" $attno "${line#*@@No@@}" + done)] ] [table width="100%" [col width=30*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] [col width=10*] diff --git a/datetime.sh b/datetime.sh new file mode 100755 index 0000000..2b4bba9 --- /dev/null +++ b/datetime.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +[ "$include_datetime" ] && return 0 +include_datetime="$0" + +# Copyright 2023 - 2024 Paul Hänsch +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +isdate(){ + local date="$1" y m d + + if printf %s "$date" \ + | grep -xEq '[0-9]{4}-((01|03|05|07|08|10|12)-(0[1-9]|[12][0-9]|3[01])|(04|06|09|11)-(0[1-9]|[12][0-9]|30)|02-(0[1-9]|[12][0-9]))' + then # y-m-d (ISO Date) + y="${date%%-*}" d="${date##*-}" m="${date%-*}" m="${m#*-}" + elif printf %s "$date" \ + | grep -xEq '((0?1|0?3|0?5|0?7|0?8|10|12)/(0?[1-9]|[12][0-9]|3[01])|(0?4|0?6|0?9|11)/(0?[1-9]|[12][0-9]|30)|0?2-(0[1-9]|[12][0-9]))/([0-9]{2}|[0-9]{4})' + then # m/d/y (US Date) + y="${date##*/}" m="${date%%/*}" d="${date%/*}" d="${d#*/}" + elif printf %s "$date" \ + | grep -xEq '((0?[1-9]|[12][0-9]|3[01])[\./](0?1|0?3|0?5|0?7|0?8|10|12)|(0?[1-9]|[12][0-9]|30)[\./](0?4|0?6|0?9|11)|(0[1-9]|[12][0-9])[\./]0?2)[\./]([0-9]{2}|[0-9]{4})' + then # d/m/y or d.m.y (European Date / German Date) + y="${date##*.}" d="${date%%.*}" m="${date%.*}" m="${m#*.}" + else + return 1 + fi + [ $y -lt 100 -a $y -gt 50 ] && y=$((y + 1900)) + [ $y -lt 100 -a $y -le 50 ] && y=$((y + 2000)) + date="$(printf "%04i-%02i-%02i" $y ${m#0} ${d#0})" + + # leap year + if [ "$m" -eq 2 -a "$d" -eq 29 ]; then + if [ "$((y % 400))" -eq 0 ]; then + : + elif [ "$((y % 100))" -eq 0 ]; then + return 1 + elif [ "$((y % 4))" -eq 0 ]; then + : + else + return 1 + fi + fi + + printf '%04i-%02i-%02i\n' "$y" "${m#0}" "${d#0}" + return 0 +} + +istime(){ + time="$1" h= m= + + if printf %s "$time" | grep -xEq '(0?[1-9]|1[012])(:[0-5][0-9])? ?(am|AM)\.?'; then + time="${time%?[aA][mM]}" h="${time%:*}" h="$(h % 12)" + [ "$h" != "$time" ] && m="${time#*:}" || m=0 + elif printf %s "$time" | grep -xEq '(0?[1-9]|1[012])(:[0-5][0-9])? ?(pm|PM)\.?'; then + time="${time%?[aA][mM]}" h="${time%:*}" h="$(h % 12 + 12)" + [ "$h" != "$time" ] && m="${time#*:}" || m=0 + elif printf %s "$time" | grep -xEq '(0?[0-9]|1[0-9]|2[0-3]):[0-5][0-9]'; then + time="${time%?[aA][mM]}" h="${time%:*}" m="${time#*:}" + else + return 1 + fi + + printf '%02i:%02i\n' "${h#0}" "${m#0}" + return 0 +} + +numdays(){ + # return number of days in a month (i.e. 28, 29, 30, or 31) + local y="$1" m="${2#0}" + + case $m in + 1|3|5|7|10|12) + printf 31\\n + ;; + 4|6|8|9|11) + printf 30\\n + ;; + 2) if [ "$((y % 400))" -eq 0 ]; then + printf 29\\n + elif [ "$((y % 100))" -eq 0 ]; then + printf 28\\n + elif [ "$((y % 4))" -eq 0 ]; then + printf 29\\n + else + printf 28\\n + fi + ;; + *) return 1;; + esac +} diff --git a/index.cgi b/index.cgi index 276965f..71edd85 100755 --- a/index.cgi +++ b/index.cgi @@ -34,6 +34,8 @@ _PATH="$(PATH "/${PATH_INFO}")" _PATH="${_PATH#${_BASE}}" ACTION="$(GET a)" +SESSION_COOKIE + message="$(COOKIE message)" [ "$message" ] && SET_COOKIE 0 message='' @@ -64,7 +66,8 @@ yield_page() { printf ' ] [body #top class="%s" ' "$class" - printf '[ul .menu [li [a "%s/cards/" . %s]][li [a "%s/courses/" . %s]]]' "${_BASE}" "$(l10n cards)" "${_BASE}" "$(l10n courses)" + printf '[ul .menu [li [a "%s/cards/" . %s]][li [a "%s/courses/" . %s]][li [a "%s/ledgers/" . %s]]]' \ + "${_BASE}" "$(l10n cards)" "${_BASE}" "$(l10n courses)" "${_BASE}" "$(l10n ledgers)" [ "$message" ] && printf '[p #message\n%s\n]' "$(l10n "$message")" cat printf '] ]' diff --git a/l10n.sh b/l10n.sh index bddc7b7..e1c29ea 100755 --- a/l10n.sh +++ b/l10n.sh @@ -26,6 +26,7 @@ l10n_global() { # Nav Menu cards) printf %s "Teil­neh­mende";; courses) printf %s "Kurse";; + ledgers) printf %s "Bei­trä­ge";; # VCF Default PHOTO) printf %s "Foto";; @@ -78,12 +79,14 @@ l10n_global() { sYEARLY) printf "Jährlich";; # UI labels + No.) printf %s "Nr.";; year) printf %s "Jahr";; month) printf %s "Monat";; day) printf %s "Tag";; edit) printf %s "Bearbeiten";; edit_categories) printf %s "Kategorien Bearbeiten";; vcf_export) printf %s "Vcard Exportieren";; + ledger) printf %s "Buchungen";; control) printf %s "Aktionen";; delete) printf %s "entfernen";; edit_update) printf %s "Daten übernehmen";; @@ -114,6 +117,28 @@ l10n_global() { filter_cancel) printf %s "Filter löschen";; export_csv) printf %s "Liste als CSV-Datei";; + # Accounting page + Ledgers) printf %s "Buchungslisten";; + '%i IBANs are unassigned') printf %s "%i IBANs sind nicht zugewiesen";; + 'IBAN Assignments') printf %s "IBAN Zuweisung";; + 'Assign IBANs') printf %s "IBANs Zuweisen";; + 'Account') printf %s "Konto";; + + 'Accept Suggestions') printf %s "Vorschläge Akzeptieren";; + 'Ignore Suggestions') printf %s "Vorschläge Ignorieren";; + 'Submit Changes') printf %s "Änderungen Übernehmen";; + + 'Payments') printf %s "Zahlungen";; + 'Date') printf %s "Datum";; + 'Originator') printf %s "Auftraggeber";; + 'Reference Text') printf %s "Verwendungszweck";; + 'Amount') printf %s "Betrag";; + 'Balance') printf %s "Kto.Stand";; + 'Manual Record') printf %s "Manueller Eintrag";; + 'Recur Monthly') printf %s "[strike monat­lich wie­der­holen]";; + 'Credit Account') printf %s "Guthaben­konto";; + 'Submit') printf %s "Eintragen";; + # UI Labels Special course_attendance) printf %s "Kurs­teil­nahme";; vcf_seed_label) printf "Anmeld. Vorn. Nachn. Geb.Tag Geb.Monat Geb.Jahr Tel. Mobil () EMail () Notiz";; diff --git a/ledgers/account.sh b/ledgers/account.sh new file mode 100755 index 0000000..9d33299 --- /dev/null +++ b/ledgers/account.sh @@ -0,0 +1,126 @@ +#!/bin/sh + +credit() { + printf '%+04i\n' "$1" \ + | sed -E 's;[0-9]{2}$;d&;; :0 s;([0-9])([0-9]{3}[dm]);\1m\2;; t0; y;dm;,.;' +} + +if uid="$(POST uid)"; then + cfile="$(grep -lxF "UID;:${uid}" "${_DATA}/vcard/"*.vcf || grep -lxF "UID:${uid}" "${_DATA}/vcard/"*.vcf)" + REDIRECT "${_BASE}/ledgers/account.sh?card=${cfile##*/}" +fi + +. "${_EXEC}/cgilite/storage.sh" +. "${_EXEC}/pdiread.sh" +. "${_EXEC}/cards/l10n.sh" +. "${_EXEC}/cards/widgets.sh" +. "${_EXEC}/datetime.sh" + +cardfile="$(GET card |PATH)" cardfile="${cardfile##*/}" +if [ ! -f "${_DATA}/vcard/$cardfile" ]; then + SET_COOKIE 0 message="Invalid account: $cardfile" + REDIRECT "${_BASE}/ledgers/" +fi + +cledger="${_DATA}/ledgers/vcf.${cardfile%.vcf}.account" + +if tid="$(POST tid)"; then + if [ "$tid" != "$(transid "$cledger")" ]; then + SET_COOKIE 0 message="Ledger was changed since last edit" + REDIRECT "$REQUEST_URI" + fi + tdate="$(isdate "$(POST tdate)")" + tref="$(POST tref |grep -m1 -xE '.+')" + tamount="$(POST tamount \ + | sed -E ' + s;^([\+-]?[0-9]+)[\.,]([0-9][0-9])$;\1\2;; + s;^([\+-]?[0-9]+)$;&00;; + ' | grep -m1 -xE '[\+-]?[0-9]+')" + # debug "TDATE: $tdate TREF: $tref AMOUNT: $tamount" + if ! [ "$tdate" -a "$tref" -a "$tamount" ]; then + SET_COOKIE 0 message="Transaction info invalid" + REDIRECT "$REQUEST_URI" + fi + tdtstamp="$(date -ud "$tdate" +%s)" + printf '%s %i %s \ %s %i\n' \ + "${tdate}" "${tdtstamp}" "$(STRING "${cardfile%.vcf}")" \ + "$(STRING "$tref")" "${tamount}" \ + >>"${cledger}" + REDIRECT "$REQUEST_URI" +fi + +{ card="$(pdi_load "${_DATA}/vcard/$cardfile")" + cat <<-EOF + [h1 $(l10n Payments)] + [div .card #${cardfile} + [div .section .basic . $( + card_item "$card" FN GENDER NICKNAME BDAY X-ZACK-JOINDATE X-ZACK-LEAVEDATE SOUND PHOTO LOGO + )] + [div .section .phone . $(card_item "$card" TEL)] + [div .section .message . $(card_item "$card" EMAIL IMPP URL)] + [div .section .address . $(card_item "$card" ADR X-IBAN)] + [div .section .note . $(card_item "$card" NOTE)] + [div .section .attendance [h3 $(l10n course_attendance) ] [ul + $(grep -F " ${cardfile}" "$_DATA/mappings/attendance" |while read each discard; do + printf '[li [a .item .attendance href="%s/courses#%s" . %s]]' \ + "${_BASE}" "$each" \ + "$(pdi_value "$(pdi_load "$_DATA/ical/$each")" SUMMARY || l10n "(unnamed course)" |unescape |HTML)" + done |sort -k7)] + $(card_item "$card" CATEGORIES) + ] + ] + EOF + cat <<-EOF + [form method=POST + [hidden "tid" "$(transid "${cledger}")"] + [table .transactions + [thead [tr + [th .date . $(l10n Date)][th .orig . $(l10n Originator)] + [th .reference . $(l10n "Reference Text")] + [th .amount . $(l10n Amount)][th .balance . $(l10n Balance)] + ]] + [tbody + EOF + cnt="$(pdi_count "$card" X-IBAN)" + { while [ "$cnt" -gt 0 ]; do + pdi_value "$card" X-IBAN "$cnt" |RXLITERAL + cnt=$((cnt - 1)) + done + RXLITERAL "${cardfile%.vcf}"; echo + } |debug \ + | while read -r iban; do + grep -hE "^[^\t]+ [^\t]+ ${iban} " "${_DATA}/ledgers/"* + done \ + | sort -n -k2 \ + | { total=0 + while read -r date dtstamp iban accname reftext amount; do + total=$((total + amount)) + if [ "$iban" = "${cardfile%.vcf}" ]; then + printf '[tr [td .date . %s][td .orig [span . %s][span . %s]][td .reference . %s] + [td .amount . %s][td .balance . %s]]' \ + "$date" "$(l10n "Credit Account")" \ + "$(UNSTRING "$accname" |HTML)" "$(UNSTRING "$reftext" |HTML)" \ + "$(credit "$amount")" "$(credit "$total")" + else + printf '[tr [td .date . %s][td .orig [span . %s][span . %s]][td .reference . %s] + [td .amount . %s][td .balance . %s]]' \ + "$date" "$(HTML "$iban")" \ + "$(UNSTRING "$accname" |HTML)" "$(UNSTRING "$reftext" |HTML)" \ + "$(credit "$amount")" "$(credit "$total")" + fi + done + } + cat <<-EOF + [tr [th colspan=5 . $(l10n 'Manual Record')]] + [tr [td .date + [input type=date placeholder="$(l10n Date)" name=tdate value="$(date +%F)"] + [input type=checkbox id=rr_month name=recur value=monthly] + [label for=rr_month $(l10n Recur Monthly)] + ][td .orig ] + [td .reference . [textarea placeholder="$(l10n "Reference Text")" name=tref]] + [td .amount [input type=number placeholder="$(l10n Amount)" name=tamount value=0.00 step=.01]] + [td .balance [button type=submit . $(l10n Submit)]] + ]]]] + EOF +} \ +| yield_page ledgers diff --git a/ledgers/csv_upload.awk b/ledgers/csv_upload.awk new file mode 100755 index 0000000..04de4d5 --- /dev/null +++ b/ledgers/csv_upload.awk @@ -0,0 +1,100 @@ +#!/bin/awk -f + +function STRING( inp ) { + gsub(/\\/, "\\\\", inp); + gsub(/\n/, "\\n", inp); + gsub(/\r/, "\\r", inp); + gsub(/\t/, "\\t", inp); + gsub(/\+/, "\\+", inp); + gsub(/ /, "+", inp); + return inp ? inp : "\\"; +} + +function UNSTRING( inp, out, tmp ) { + while ( inp ) { + if ( inp ~ /^\\\\/) { out = out "\\"; sub(/^\\\\/, "", inp); } + else if ( inp ~ /^\\n/) { out = out "\n"; sub(/^\\n/, "", inp); } + else if ( inp ~ /^\\r/) { out = out "\r"; sub(/^\\r/, "", inp); } + else if ( inp ~ /^\\t/) { out = out "\t"; sub(/^\\t/, "", inp); } + else if ( inp ~ /^\\+/) { out = out "+"; sub(/^\\+/, "", inp); } + else if ( inp ~ /^\+/) { out = out " "; sub(/^\+/, "", inp); } + else if ( inp ~ /^\\/) { out = out ""; sub(/^\+/, "", inp); } + else { tmp = inp; sub(/[\\+].*$/, "", tmp); out = out tmp; sub(/^[^\\+]*/, "", inp); } + } + return out; +} + +function isdate( date, dt, y, m, d ) { + if ( match( date, + /^[0-9]{4}-((01|03|05|07|08|10|12)-(0[1-9]|[12][0-9]|3[01])|(04|06|09|11)-(0[1-9]|[12][0-9]|30)|02-(0[1-9]|[12][0-9]))$/ )) { + split( date, dt, "-"); + y = dt[1]; m = dt[2]; d = dt[3]; + + } else if ( match( date, + /^((0?1|0?3|0?5|0?7|0?8|10|12)\/(0?[1-9]|[12][0-9]|3[01])|(0?4|0?6|0?9|11)\/(0?[1-9]|[12][0-9]|30)|0?2\/(0[1-9]|[12][0-9]))\/([0-9]{2}|[0-9]{4})$/ )) { + split( date, dt, "/"); + m = dt[1]; d = dt[2]; y = dt[3]; + + } else if ( match( date, + /^((0?[1-9]|[12][0-9]|3[01])[\.\/](0?1|0?3|0?5|0?7|0?8|10|12)|(0?[1-9]|[12][0-9]|30)[\.\/](0?4|0?6|0?9|11)|(0[1-9]|[12][0-9])[\.\/]0?2)[\.\/]([0-9]{2}|[0-9]{4})$/ )) { + split( date, dt, /[\.\/]/); + d = dt[1]; m = dt[2]; y = dt[3]; + + } else return ""; + + if ( y < 100 && y > 50 ) y = y + 1900; + if ( y <= 50 ) y = y + 2000; + + # leap year + if ( m == 2 && d == 29 ) { + if ( y % 400 == 0 ) y = y; + else if ( y % 100 == 0 ) return ""; + else if ( y % 4 == 0 ) y = y; + else return ""; + } + + return sprintf("%04i-%02i-%02i", y, m, d); +} + +function cents( val ) { + gsub(/\./, "", val); sub(/,/, ".", val); + return val * 100; +} + +BEGIN { + FS = ";"; + dtrange_end = dt_from = dt_to = balance_start = balance_end = ""; + split("", rec); + rec[0] = "Date DateU IBAN Name Subject Amount" +} + +/^([012]?[0-9]|30|31).(0?[1-9]|1[012]).[0-9]{4} - ([012]?[0-9]|30|31).(0?[1-9]|1[012]).[0-9]{4}$/ { + dtrange_end = $0; sub(/^.* - /, "", dtrange_end); + dt_from = $0; sub(/ - .*$/, "", dt_from); dt_from = isdate(dt_from); + dt_to = $0; sub(/^.* - /, "", dt_to ); dt_to = isdate(dt_to ); +} + +/^Letzter Kontostand;;;;[0-9\.,]+;EUR$/ { + balance_start = cents($5); +} + +/Kontostand;[^;]+;;;[0-9\.,]+;EUR/ { + if ( $2 = dtrange_end ) balance_end = cents($5) +} + +$18 == "EUR" { + rec_date = isdate($1); gsub(/-/, " ", rec_date); rec_date = mktime(rec_date " 00 00 00", "UTC"); + rec[length(rec)] = sprintf("%s %i %s %s %s %i", + isdate($1), rec_date, $6 ? $6 : "\\", STRING($4), STRING($5), cents($12)); +} + +END { + if ( dt_from && dt_to ) { + dtu_from = dt_from; gsub(/-/, " ", dtu_from); dtu_from = mktime( dtu_from " 00 00 00", "UTC"); + dtu_to = dt_to ; gsub(/-/, " ", dtu_to ); dtu_to = mktime( dtu_to " 00 00 00", "UTC"); + + printf "%i %s %i %s %i %i\n", + dtu_from, dt_from, dtu_to, dt_to, balance_start, balance_end; + for ( k = 1; k < length(rec); k++ ) print rec[k]; + } +} diff --git a/ledgers/csv_upload.sh b/ledgers/csv_upload.sh new file mode 100755 index 0000000..4d25b5c --- /dev/null +++ b/ledgers/csv_upload.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +# Copyright 2024 Paul Hänsch +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +if [ "${CONTENT_TYPE%%;*}" != "multipart/form-data" ]; then + SET_COOKIE 0 message="Not an upload" + REDIRECT "${_BASE}/ledgers/" +fi + +. "$_EXEC/multipart.sh" +multipart_cache + +# Validate session id from form to prevent CSRF +if [ "$(multipart session_id)" != "$SESSION_ID" ]; then + rm -- "$multipart_cachefile" + SET_COOKIE 0 message="INVALID SESSION ID IN FORM" + REDIRECT "${_BASE}/ledgers/" +fi + +mkdir -p "$_DATA/ledgers/" +CSV="$(multipart "csv" 1 | "$_EXEC/ledgers/csv_upload.awk")" +rm -- "$multipart_cachefile" + +read dtu_start dt_start dtu_end dt_end balance_start balance_end <<-EOF + ${CSV%%${BR}*} +EOF + +if [ ! "$dtu_end" -o ! "$dtu_start" ] || [ "$dtu_end" -lt "$dtu_start" ]; then + SET_COOKIE 0 message="No valid date range in upload" +else + num=0; while [ ! "$filename" -o -f "$_DATA/ledgers/$filename" ]; do + num=$((num + 1)); filename="${dt_start} - ${dt_end} - $(printf '%04i' $num).tbl" + done + printf '%s\n' "$CSV" >"$_DATA/ledgers/$filename" +fi + +REDIRECT "${_BASE}/ledgers/" diff --git a/ledgers/delete.sh b/ledgers/delete.sh new file mode 100755 index 0000000..7b41921 --- /dev/null +++ b/ledgers/delete.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +delete="$(POST delete |PATH)" +dtable="${_DATA}/ledgers/${delete##*/}" + +if [ "$dtable" ]; then + rm -- "${dtable}" \ + || SET_COOKIE 0 message="Unable to delete ledger: \"$(HTML "$delete")\"" +else + SET_COOKIE 0 message="No such ledger: \"$(HTML "$delete")\"" +fi + +REDIRECT "${_BASE}/ledgers/" diff --git a/ledgers/iban_assign.awk b/ledgers/iban_assign.awk new file mode 100755 index 0000000..0491617 --- /dev/null +++ b/ledgers/iban_assign.awk @@ -0,0 +1,92 @@ +#!/bin/awk -f + +function dbg( text ) { print text >>"/dev/stderr"; } + +function STRING( inp ) { + gsub(/\\/, "\\\\", inp); + gsub(/\n/, "\\n", inp); + gsub(/\r/, "\\r", inp); + gsub(/\t/, "\\t", inp); + gsub(/\+/, "\\+", inp); + gsub(/ /, "+", inp); + return inp ? inp : "\\"; +} + +function UNSTRING( inp, out, tmp ) { + while ( inp ) { + if ( inp ~ /^\\\\/) { out = out "\\"; sub(/^\\\\/, "", inp); } + else if ( inp ~ /^\\n/) { out = out "\n"; sub(/^\\n/, "", inp); } + else if ( inp ~ /^\\r/) { out = out "\r"; sub(/^\\r/, "", inp); } + else if ( inp ~ /^\\t/) { out = out "\t"; sub(/^\\t/, "", inp); } + else if ( inp ~ /^\\+/) { out = out "+"; sub(/^\\+/, "", inp); } + else if ( inp ~ /^\+/) { out = out " "; sub(/^\+/, "", inp); } + else if ( inp ~ /^\\/) { out = out ""; sub(/^\+/, "", inp); } + else { tmp = inp; sub(/[\\+].*$/, "", tmp); out = out tmp; sub(/^[^\\+]*/, "", inp); } + } + return out; +} + +function rx( regex ) { + gsub(/[].*+?^${}()|\\[]/, "\\\\&", regex); + return regex; +} + +BEGIN { + FS = " "; ledger = 0; + fn = n = uid = iban = tmp = ""; + split("", uid_n); split("", uid_fn); split("", uid_iban); + split("", iban_uid); split("", ibans); split("", uids); + + split("", sure); split("", unsure); split("", unknown); + split("", unsure_rec) +} + +/^BEGIN;:VCARD$/ { fn = n = id = iban = tmp = ""; } + + /^UID;[^:]*:/ { uid = $0; sub(/^[^;]+;[^:]*:/, "", uid); } + /^FN;[^:]*:/ { fn = $0; sub(/^[^;]+;[^:]*:/, "", fn); } + /^N;[^:]*:/ { n = $0; sub(/^[^;]+;[^:]*:/, "", n); sub(/;.*$/, "", n); } +/^X-IBAN;[^:]*:/ { iban = $0; sub(/^[^;]+;[^:]*:/, "", iban); ibans[length(ibans)] = iban; } + +/^END;:VCARD$/ { + uid_n[uid] = n; uid_fn[uid] = fn; uid_iban[uid] = iban; + for (iban in ibans) iban_uid[ibans[iban]] = iban_uid[ibans[iban]] ? iban_uid[ibans[iban]] " " uid : uid; + fn = n = uid = iban = tmp = ""; split("", ibans); +} + +/^BEGIN:LEDGERS$/ { ledger = 1; } + +ledger && strftime("%Y-%m-%d", $2, "UTC") == $1 { + if ($3 in iban_uid) { + sure[$3] = iban_uid[$3]; + } else { + for (uid in uid_fn) if ( match(UNSTRING($5), rx(uid_fn[uid])) ) { + if (! match(unsure[$3], rx(uid))) unsure[$3] = unsure[$3] ? unsure[$3] " " uid : uid; + unsure_rec[$3] = $0 + } + for (uid in uid_n) if ( uid_n[uid] && match(UNSTRING($4), rx(uid_n[uid])) ) { + if (! match(unsure[$3], rx(uid))) unsure[$3] = unsure[$3] ? unsure[$3] " " uid : uid; + unsure_rec[$3] = $0 + } + } + if (!($3 in sure) && !($3 in unsure)) unknown[$3] = $0; +} + +END { + for (iban in unsure) { + line = "guess " iban " " STRING(unsure_rec[iban]); + split(unsure[iban], uids, / /); + for (k in uids) line = line " " STRING(uids[k] "/" uid_fn[uids[k]]); + print line; + } + for (iban in unknown) { + line = "unknown " iban " " unknown[iban]; + print line; + } + for (iban in sure) { + line = "sure " iban; + split(sure[iban], uids, / /); + for (k in uids) line = line " " STRING(uids[k] "/" uid_fn[uids[k]]); + print line; + } +} diff --git a/ledgers/iban_assign.sh b/ledgers/iban_assign.sh new file mode 100755 index 0000000..778d279 --- /dev/null +++ b/ledgers/iban_assign.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +credit() { + printf '%03i\n' "$1" \ + | sed -E 's;[0-9]{2}$;d&;; :0 s;([0-9])([0-9]{3}[dm]);\1m\2;; t0; y;dm;,.;' +} + +{ printf ' + [h1 . %s] + [form .ibanassign action="%s/ledgers/set_iban.sh" method=POST + [input type=hidden name=session_id value="%s"] + ' "$(l10n "IBAN Assignments")" "${_BASE}" "$SESSION_ID" + printf '[datalist id=lattendants .' + pdi_load "${_DATA}"/vcard/*.vcf |sed -n '/^FN\;:/!b; s;^FN\;:;;; p;' \ + | while read name; do + printf '[option value="%s"]' "$(HTML "$name")" + done + printf ']' + l10n_attendant="$(l10n attendant)" + printf %s\\n "$IBAN_ASSIGN" \ + | while read -r state iban data; do + iban="$(UNSTRING "$iban")" + [ ! "$iban" ] && iban="??????????" + printf '[input type=checkbox id="use_%s" name="use_%s" value=true]' "$iban" "$iban" + printf '[fieldset .iban .%s [legend . %s ]' \ + "$state" "$iban" + if [ $state = sure ]; then + for card in $data; do + uid="${card%%/*}" name="$(UNSTRING "${card#*/}")" + printf '[span .card . %s]' "$(HTML "${name}")" + done + : + elif [ $state = guess ]; then + record="$(UNSTRING "${data%% *}")" + cards="${data#* }" + date="${record%% *}" + principal="${record#* * * }" principal="${principal%% *}" + subject="${record#* * * * }" subject="${subject%% *}" + amount="${record#* * * * * }" amount="${amount%% *}" + printf '[p .principal . %s][p .date %s][p .amount %s][p .subject . %s]' \ + "$(UNSTRING "$principal" |HTML)" "$date" "$(credit "$amount")" "$(UNSTRING "$subject" |HTML)" + n=0; for card in $cards; do + n=$((n+1)); uid="${card%%/*}" name="$(UNSTRING "${card#*/}")" + cat <<-EOF + [input type=checkbox id="check_${iban}_$n" name="check_${iban}_$n" value=true checked=checked] + [input .card name="fn_${iban}_$n" value="$(HTML "$name")" .disabled tabindex="-1"] + [label .del for="check_${iban}_$n" . -] + EOF + done + for m in 1 2 3 4 5 6 7 8; do + cat <<-EOF + [input type=checkbox id="check_${iban}_$((n+m))" name="check_${iban}_$((n+m))" value=true] + [input .card name="fn_${iban}_$((n+m))" value="" placeholder="${l10n_attendant}" list="lattendants"] + [label .add for="check_${iban}_$((n+m))" . +] + EOF + done + printf '[label .button for="use_%s" . %s]' "$iban" "$(l10n Accept Suggestions)" + printf '[label .button for="use_%s" . %s]' "$iban" "$(l10n Ignore Suggestions)" + elif [ $state = unknown ]; then + date="${data%% *}" + principal="${data#* * * }" principal="${principal%% *}" + subject="${data#* * * * }" subject="${subject%% *}" + amount="${data#* * * * * }" amount="${amount%% *}" + printf '[p .principal . %s][p .date %s][p .amount %s][p .subject . %s]' \ + "$(UNSTRING "$principal" |HTML)" "$date" "$(credit "$amount")" "$(UNSTRING "$subject" |HTML)" + printf '[input name="check_" type=hidden][input type=hidden][label .del]' + n=0; for m in 1 2 3 4 5 6 7 8; do + cat <<-EOF + [input type=checkbox id="check_${iban}_$((n+m))" name="check_${iban}_$((n+m))" value=false] + [input .card name="fn_${iban}_$((n+m))" value="" placeholder="${l10n_attendant}" list="lattendants"] + [label .add for="check_${iban}_$((n+m))" . +] + EOF + done + printf '[label .button for="use_%s" . %s]' "$iban" "$(l10n Accept Suggestions)" + printf '[label .button for="use_%s" . %s]' "$iban" "$(l10n Ignore Suggestions)" + fi + printf ']' + done + printf '[button type=submit . %s]' "$(l10n Submit Changes)" + printf ' ]' +} | yield_page ledgers diff --git a/ledgers/index.cgi b/ledgers/index.cgi new file mode 100755 index 0000000..f5ac6b5 --- /dev/null +++ b/ledgers/index.cgi @@ -0,0 +1,76 @@ +#!/bin/sh + +. "$_EXEC/cgilite/storage.sh" +. "$_EXEC/pdiread.sh" + +if [ "$(POST show_account)" ]; then + uid="$(POST show_account)" + cfile="$(grep -lxF "UID;:${uid}" "${_DATA}/vcard/"*.vcf || grep -lxF "UID:${uid}" "${_DATA}/vcard/"*.vcf)" + REDIRECT "${_BASE}/ledgers/account.sh?card=${cfile##*/}" + exit 0; +fi + +credit() { + printf '%03i\n' "$1" \ + | sed -E 's;[0-9]{2}$;d&;; :0 s;([0-9])([0-9]{3}[dm]);\1m\2;; t0; y;dm;,.;' +} + +IBAN_ASSIGN="$( + { pdi_load "${_DATA}"/vcard/*.vcf + printf 'BEGIN:LEDGERS\n' + cat "${_DATA}"/ledgers/????-??-??\ -\ ????-??-??\ -\ ????.tbl + } | "${_EXEC}"/ledgers/iban_assign.awk + printf '\n' +)" + +if [ "${PATH_INFO%/iban_assign/}" != "${PATH_INFO}" ]; then + . "${_EXEC}/ledgers/iban_assign.sh" + exit 0 +fi + +{ printf ' + [form .upload action="%s/ledgers/csv_upload.sh" method="POST" enctype="multipart/form-data" + [label for=ledger_upload . %s:] + [input #ledger_upload type="file" name="csv" accept=".csv,text/csv"] + [input type=hidden name=session_id value="%s"] + [button type="submit" %s] + ]' \ + "${_BASE}" "$(l10n "Postbank CSV")" "$SESSION_ID" "$(l10n Upload)" + printf ' + [form .ledgers action="%s/ledgers/delete.sh" method=POST + [input type=hidden name=session_id value="%s"] + [h3 . %s] + ' "${_BASE}" "$SESSION_ID" "$(l10n Ledgers)" + for ledger in "$_DATA"/ledgers/????-??-??\ -\ ????-??-??\ -\ ????.tbl; do + ledger="${ledger##*/}" + [ "$ledger" = "????-??-?? - ????-??-?? - ????.tbl" ] && continue + printf '[p .ledger . %s [button type=submit name=delete value="%s" . %s]]' \ + "$(HTML "${ledger% - ????.tbl}")" "$(HTML "$ledger")" "$(l10n delete)" + done + printf ' ]' + unassigned="$(printf %s\\n "$IBAN_ASSIGN" |grep -E '^guess|^unknown' |wc -l)" + cat <<-EOF + [div + [h1 . $(l10n IBAN Assignments)] + $(printf "$(l10n "%i IBANs are unassigned")" "$unassigned") + [a href="${_BASE}/ledgers/iban_assign/" . $(l10n Assign IBANs)] + ] + [form action="${_BASE}/ledgers/account.sh" method=POST + [select name=uid + $(printf %s\\n "$IBAN_ASSIGN" \ + | sed -E ' + /^sure /!d; + s;^sure [^\t]+;;; + s;([^\t]+)/([^\t]+);\1 \2\n;g; + s;\n$;; + ' \ + | while read uid fn; do + uid="$(HTML "$uid")" + fn="$(UNSTRING "$fn" |HTML)" + printf '[option value="%s" . %s]' "$uid" "$fn" + done) + ] + [button type="submit" . $(l10n Account)] + ] + EOF +} | yield_page ledgers diff --git a/ledgers/set_iban.sh b/ledgers/set_iban.sh new file mode 100755 index 0000000..08c9a3b --- /dev/null +++ b/ledgers/set_iban.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +. "$_EXEC/pdiread.sh" +. "$_EXEC/session_lock.sh" + +UIDLIST="$( + pdi_load "$_DATA/vcard/"*.vcf \ + | sed -Ez ' + s/\nBEGIN;:VCARD\n([^\n]+\n)*FN;:([^\n]+)\n([^\n]+\n)*UID;:([^\n]+)\n([^\n]+\n)*END;:VCARD\n/UID:\4 FN:\2/g + ' + echo +)" + +for key in $(POST_KEYS); do case $key in + use_*) + use_iban="${use_iban} ${key#use_} " + ;; +esac; done + +[ "$use_iban" ] && for key in $(POST_KEYS); do case $key in + check_*_*) + iban="${key#check_}" iban="${iban%_*}" + [ ! "${use_iban##* "${iban}" *}" ] && check="${check} ${key#check_} " + ;; +esac; done + +{ printf '[ul .results' + for use in $check; do + iban="${use%_*}" + fn="$(POST "fn_${use}")" + uid="${UIDLIST%% FN:"$fn"${BR}*}" uid="${uid##*${BR}UID:}" + + # cfile="${_DATA}/vcard/${uid}.vcf" + cfile="$(grep -lxF "UID;:${uid}" "${_DATA}/vcard/"*.vcf || grep -lxF "UID:${uid}" "${_DATA}/vcard/"*.vcf)" + if SLOCK "$cfile" >/dev/null; then + card="$(pdi_load "$cfile")" + cnum="$(pdi_count "$card" X-IBAN)" + pdi_update_value "$card" X-IBAN "$((cnum + 1))" "$iban" >"$cfile" + printf '[li .success . [span .name . %s] [span .uid . (UID: %s)] assigned IBAN [span .iban . %s]]' \ + "$(HTML "$fn")" "$(HTML "$uid")" "$(HTML "$iban")" + RELEASE_SLOCK "$cfile" + else + printf '[li .error . [span .name . %s] [span .uid . (UID: %s)] is being edited elsewhere]' + "$(HTML "$fn")" "$(HTML "$uid")" + fi + done + printf ']' + printf '[a .button href="%s/ledgers/iban_assign/" . %s]' "${_BASE}" "$(l10n Back)" +} | yield_page ledgers_assign diff --git a/lua/vcard.lua b/lua/vcard.lua new file mode 100644 index 0000000..3066737 --- /dev/null +++ b/lua/vcard.lua @@ -0,0 +1,171 @@ +#!/usr/bin/lua + +local Vcard = { + _component_fields = { N = true; GENDER = true; ADR = true; ORG = true; } +} + +-- constructor, synonymous to load() +function Vcard:new(path) + return self:load(path) +end + +-- constructor +-- load and parse vcard file, return new vcard object +function Vcard:load(path) + local file, data + + file = io.open(path) + if not file then return nil; end + data = file:read("a") + file:close() + + return self:parse(data) +end + +-- constructor +-- parse text block of a vcard, return new vcard object +function Vcard:parse(data) + local line, key, attr, value, i, v, n + local r = {} + setmetatable(r, { __index = self }) + + -- unwrap line continuations, remove carriage return at EOL + data = data:gsub("\r?\n[ \t]", "") + data = data:gsub("\r\n", "\n") + + -- parse lines into fields + for line in data:gmatch("[^\n]+") do + key, attr, value = self:_parse_line(line) + if not r[key] then r[key] = { attr = {} } end + if Vcard._component_fields[key] then + table.insert(r[key], self:components(value)); + r[key].attr[#r[key]] = attr + r[key][#r[key]][0] = value; + else for i,v in ipairs(self:_split_by(value, ",")) do + table.insert(r[key], v) + r[key].attr[#r[key]] = attr + end end + end + + -- try to ensure existence of FN field + if not r["FN"] and r["N"] then + n = r["N"][1] + r["FN"] = { (n[4] or " ") .. " " .. (n[2] or " ") .. " " .. + (n[3] or " ") .. " " .. (n[1] or " ") .. " " .. + (n[5] or " ") } + r["FN"][1] = r["FN"][1]:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "") + end + if not r["FN"] and r["NICKNAME"] then + r["FN"] = { r["NICKNAME"][1] } + r["FN"][1] = r["FN"][1]:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "") + end + + return r +end + +-- split field components (i.e. the N field) +function Vcard:components(line) + return self:_split_by(line, ";") +end + +function Vcard:unescape(line) + local f local out = "" + + while line ~= "" and line ~= "\\" do + if (function() f = line:match("^\\n([^\\]*)") return f end)() then + out = out .. "\n" .. f + line = line:sub(#f + 3) + elseif (function() f = line:match("^\\(.[^\\]*)") return f end)() then + out = out .. f + line = line:sub(#f + 2) + elseif (function() f = line:match("^([^\\]+)") return f end)() then + out = out .. f + line = line:sub(#f + 1) + end + end + + return out +end + +function Vcard:escape(line) + return line:gsub("\\", "\\\\"):gsub(",", "\\,"):gsub(";", "\\;"):gsub("\n", "\\n") +end + +-- internal +-- split vcard line into key, value, and an array of attributes +function Vcard:_parse_line(line) + local key, value local attr = {} + + key = line:match('^([^:;]+)[;:].*$') + if not key then return nil end + + line = line:sub(#key + 1 ) + a = line:match('^;([^";:]+)[;:].*$') or line:match('^;([^"=;:]+="[^"]+")[;:].*$') + while a do + table.insert(attr, a) + line = line:sub(#a + 2) + a = line:match('^;([^";:]+)[;:].*$') or line:match('^;([^"=;:]+="[^"]+")[;:].*$') + end + + value = line:match("^:(.*)$") + if not value then return nil end + + key = key:upper() + return key, attr, value +end + +-- internal +-- split string by specified character, unless the character is ecapped by \ +function Vcard:_split_by(line, split) + local f local r = { "" } + + while line ~= "" and line ~= "\\" do + if line:match("^" .. split) then + table.insert(r, "") + line = line:sub(2) + end + f = line:match("^(\\.)") or line:match("^([^\\" .. split .. "]+)") + r[#r] = r[#r] .. f + line = line:sub(#f + 1) + end + return r +end + +-- internal +-- development tests +function Vcard:_test() + local vcf = self:parse([=[ +BEGIN:VCARD +VERSION:1.0 +UID:1 +N:Lastname;Firstname;Middle Names; Title; Suffix +NICKNAME: Bone Crusher, Cookie Monster +PHONE;TYPE=Home:123456789,666999 +PHONE;TYPE=Work;TYPE=Cell:987654321 +END:VCARD +]=]) + + assert( vcf["PHONE"][1] == "123456789", "Phone/1 wrong number" ) + assert( vcf["PHONE"][2] == "666999", "Phone/2 wrong number" ) + assert( vcf["PHONE"].attr[2][1] == vcf["PHONE"].attr[1][1] ) + assert( vcf["PHONE"][3] == "987654321", "Phone/3 wrong number" ) + assert( vcf["PHONE"].attr[3][2] == "TYPE=Cell", "Phone/3 attr type=cell" ) + assert( vcf["FN"][1] == "Title Firstname Middle Names Lastname Suffix" ) + assert( vcf["N"][1][1] == "Lastname" ) + assert( vcf["N"][1][4] == " Title" ) + + vcf = self:parse([=[ +BEGIN:VCARD +VERSION:1.0 +UID:1 +NICKNAME: Bone Crusher, Cookie Monster +PHONE;TYPE=Home:123456789,666999 +PHONE;TYPE=Work;TYPE=Cell:987654321 +END:VCARD +]=]) + assert( vcf["FN"][1] == "Bone Crusher" ) + + print("Vcard OK") +end + +return Vcard diff --git a/multipart.sh b/multipart.sh new file mode 100755 index 0000000..02f7dfb --- /dev/null +++ b/multipart.sh @@ -0,0 +1,105 @@ +#!/bin/sh + +[ "$include_multipart" ] && return 0 +inlude_multipart="$0" + +# Copyright 2022 - 2023 Paul Hänsch +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +if [ "${CONTENT_TYPE}" -a ! "${CONTENT_TYPE##multipart/form-data;*}" ]; then + multipart_boundary="${CONTENT_TYPE#*; boundary=}" + multipart_boundary="${multipart_boundary%%;*}" + multipart_boundary="${multipart_boundary%${CR}}" +fi +multipart_cachefile="/tmp/multipart.$$" + +readbytes(){ + # read n bytes, like `head -c` but do not consume input + local size="$1" block + + for block in 65536 32768 16384 8192 4096 2048 1024 512 256 128 64 32 16 8 4 2 1; do + if [ $size -ge $block ]; then + dd status=none bs="$block" count="$((size / block))" + size="$((size % block))" + fi + done +} + +multipart_cache() { + multipart_cachefile="${1:-${multipart_cachefile}}" # global + + if [ "${multipart_boundary}" ]; then + # readbytes "$(( CONTENT_LENGTH ))" >"${multipart_cachefile}" + head -c "$(( CONTENT_LENGTH ))" >"${multipart_cachefile}" + else + return 1 + fi +} + +multipart(){ + local name="$1" count="${2:-1}" + local formdata state=begin + + while IFS='' read -r formdata; do case "$formdata" in + "--${multipart_boundary}--${CR}") + [ $state = data ] && return 0 \ + || return 1 + ;; + "--${multipart_boundary}${CR}") + [ $state = data ] && return 0 \ + || state=header + ;; + "Content-Disposition: form-data; name=\"${name}\""*"${CR}") + [ $state = header -a $count -eq 1 ] && state=dheader + [ $state = header -a $count -gt 1 ] && count=$((count - 1)) + [ $state = data ] && printf "%s\n" "$formdata" + ;; + "${CR}") + if [ $state = dheader ]; then + # Do not use `sed -n` (or busybox sed will "convert" NULL to LF) + sed "/--${multipart_boundary}\(--\)\?${CR}/{x;q;}" \ + | head -c-3 + return 0; + fi + [ $state = header ] && state=junk + ;; + esac; done <"${multipart_cachefile}" +} + +multipart_filename(){ + local name="$1" count="${2:-1}" + local formdata state=begin + + while read -r formdata; do case "$formdata" in + "--${multipart_boundary}--${CR}") + return 1 + ;; + "--${multipart_boundary}${CR}") + state=header + ;; + "Content-Disposition: form-data; name=\"${name}\"; filename=\""*"\""*"${CR}") + [ $state = header -a $count -eq 1 ] && break + [ $state = header -a $count -gt 1 ] && count=$((count - 1)) + ;; + "${CR}") + [ $state = header ] && state=junk + ;; + esac; done <"${multipart_cachefile}" + + filename="${formdata#*; filename=\"}" + filename="${filename%%\"${CR}}" + filename="${filename%%\";*}" + + HEX_DECODE % "$filename" +} diff --git a/style.css b/style.css index 298ae3d..9e6bc02 100644 --- a/style.css +++ b/style.css @@ -25,8 +25,9 @@ body > .menu a { padding: .5em 3em; box-shadow: inset 0 0 .5em #000; } -body.cards > .menu a[href$="/cards/"], -body.courses > .menu a[href$="/courses/"] { +body.ledgers > .menu a[href$="/ledgers/"], +body.courses > .menu a[href$="/courses/"], +body.cards > .menu a[href$="/cards/"] { color: #000; background-color: #FFF; box-shadow: none; @@ -34,9 +35,9 @@ body.courses > .menu a[href$="/courses/"] { /* =========== FILTER AND SEARCH Headers ========= */ -form.categories, +form.upload, form.categories, form.search, form.sort, form.filter, form.newcard, form.newcourses { - margin-top: 1em; padding: 0 1em; + margin-top: 1em; padding: .125em 1em 0 1em; z-index: 1; } form.filter > h1 { display: none; } @@ -64,10 +65,17 @@ form.filter button[type=submit] { form.filter button[value=export_csv] { margin-left: 1em; } body.courses form .order { display: inline-block; margin-right: 2em;} +body.courses form.search.sort fieldset { margin-top: .5em; } body.cards form.newcard { display: flex; } body.cards form.newcard input[name=seed] { flex: 1; } +form.upload label { + display: block; + font-weight: bold; + margin-top: .5em; +} + /* ============ LIST ITEMS, Generic ============= */ @@ -359,3 +367,171 @@ body.categories form.namelist ul.namelist > li h2 { body.categories form.namelist ul.namelist > li ul li { display: inline-block; } + + +/* ======== Ledgers Page ======== */ + +form.ibanassign, +form.ledgers { + padding: .125em 1em 0 1em; +} + +.ibanassign fieldset.iban.sure { background-color: #DFD; } +.ibanassign fieldset.iban.guess { background-color: #FFD; } +.ibanassign fieldset.iban.unknown { background-color: #FDD; } + +.ibanassign fieldset.iban { + padding: 0 .75em; + margin-top: -.5em; + box-shadow: .25em .25em .25em #AAA; +} +.ibanassign fieldset.iban legend { + top: .75em; +} +.ibanassign fieldset.iban p.principal { + font-size: .875em; +} +.ibanassign fieldset.iban p.date, +.ibanassign fieldset.iban p.amount { + font-size: .875em; + display: inline-block; + vertical-align: top; + margin-right: .75em; + margin-bottom: 0; +} +.ibanassign fieldset.iban p.amount { + font-weight: bold; +} + +.ibanassign fieldset.iban.sure .card { margin-right: 1em; } + +.ibanassign fieldset.iban input[name^="fn_"].disabled { + pointer-events: none; +} +.ibanassign fieldset.iban input[name^="check_"], +.ibanassign fieldset.iban input[name^="check_"] + input, +.ibanassign fieldset.iban input[name^="check_"] + input + label { + display: none; +} +.ibanassign fieldset.iban input[name^="check_"]:checked + input, +.ibanassign fieldset.iban input[name^="check_"]:checked + input + label.del, +.ibanassign fieldset.iban input[name^="check_"] + input + label.del + input + input + label.add, +.ibanassign fieldset.iban input[name^="check_"]:checked + input + label + input + input + label.add { + display: inline; +} +.ibanassign fieldset.iban input[name^="check_"]:checked + input + label.add, +.ibanassign fieldset.iban input[name^="check_"] + input + label.del + input:checked + input + label.add, +.ibanassign fieldset.iban input[name^="check_"]:checked + input + label + input:checked + input + label.add { + display: none; +} + +.ibanassign fieldset.iban input[name^="check_"] + input + label { + vertical-align: bottom; + line-height: 2.5em; + padding: .375em .625em; + border: .5pt solid; +} +.ibanassign fieldset.iban input[name^="check_"] + input + label.add { + background-color: #DFD; + border-radius: 2pt; +} +.ibanassign fieldset.iban input[name^="check_"] + input + label.del { + margin: 0 .5em 0 -.25em; + background-color: #FDD; + border-radius: 0 2pt 2pt 0; +} + +.ibanassign input[name^="use_"] { + display: none; +} +.ibanassign input[name^="use_"]:checked + fieldset.iban.guess { background-color: #EFD; } +.ibanassign input[name^="use_"]:checked + fieldset.iban.unknown { background-color: #FED; } +.ibanassign input[name^="use_"]:checked + fieldset.iban input { + background-color: #DFD; + pointer-events: none; + border-color: #888; +} +.ibanassign input[name^="use_"]:checked + fieldset.iban label.del, +.ibanassign input[name^="use_"]:checked + fieldset.iban label.add { + display: none !important; +} + +.ibanassign fieldset.iban label[for^="use_"] { + display: block; + float: right; + padding: .25em .5em; + background-color: #DDF; + border: 1pt solid; +} + +.ibanassign input[name^="use_"] + fieldset.iban label[for^="use_"] { display: block; } +.ibanassign input[name^="use_"] + fieldset.iban label[for^="use_"] + label[for^="use_"] { display: none; } +.ibanassign input[name^="use_"]:checked + fieldset.iban label[for^="use_"] { display: none; } +.ibanassign input[name^="use_"]:checked + fieldset.iban label[for^="use_"] + label[for^="use_"] { display: block; } + +.ibanassign > button { + position: sticky; + bottom: 0; + margin: auto; + display: block; +} + +body.ledgers .transactions { + width: 98%; + width: calc(100% - 2em); + margin: auto; +} +body.ledgers .transactions thead { + position: sticky; + top: 0; + z-index: 1; +} +body.ledgers .transactions th { + text-align: left; + background-color: #FFF; +} +body.ledgers .transactions td { + vertical-align: top; + font-family: monospace; + font-size: 12pt; +} + +body.ledgers .transactions td:nth-child(2n) { + background-color: #DDD; +} +body.ledgers .transactions td:nth-child(2n + 1) { + background-color: #EEE; +} + +body.ledgers .transactions .date { + width: 10em; +} +body.ledgers .transactions .orig span { + display: block; +} +body.ledgers .transactions .amount, +body.ledgers .transactions .balance { + vertical-align: bottom; + width: 8em; + text-align: right; +} + +body.ledgers .transactions .reference textarea { + width: 100%; +} +body.ledgers .transactions .date input + label { + display: inline-block; + vertical-align: middle; + width: 7em; +} +body.ledgers .transactions .date input[type=date], +body.ledgers .transactions .amount input[type=number] { + display: block; + width: 100%; +} + +body.ledgers .transactions .orig input:not(:checked) + label + input[type=date] { + background-color: #DDD; + border-color: #888; + pointer-events: none; +}