epsi2ics/epsiEDTtoICS.sh

374 lines
11 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/bin/bash
# The epsi2ics project produces an ICS file from an EPSI school-year calendar.
# Copyright © 2019 Y. Gablin, under the GPL-3.0-or-later license.
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
#
# This script only works when executed in the timezone of the calendar, and only if this timezone offset
# is low enough to keep all events in the day they belong to in the web calendar (usually 6h ≤ TZ ≤ +8h).
#
# From EPSI “edtmobilityeng” to ICS, for the current school year. Usage:
#
# epsiEDTtoICS.sh -u <user> [ -c [ <path> | - ] ] [ -j ] [ -m <to> ]
# epsiEDTtoICS.sh -u <user> [ -p [ <path> | - ] [ -w <count> ] ] [ -j ] [ -m <to> ]
# <user>: EPSI Login (firstname.lastname)
# <path>: Location where the ICS file is created (-c) or patched (-p)
# <count>: Number of weeks to update (including the current one), if -p is specified
# <to>: Comma-separated list of email recipients (needs sendmail in the PATH)
#
# If no path is given, “-” is assumed, which is a placeholder for the standard output.
# If neither -c nor -p is given, “-c -” is assumed.
# If -p is used with “-” as a path, then only the updated weeks are sent to the standard output.
# If -p is given without -w, the default count of updated weeks is 5.
# If -j is used, logs are sent to systemd-journald instead of the standard error (needs systemd-cat).
# If -m is used, the given recipients are notified of changes between runs
LANG8BIT=fr_FR@euro
LOGIN=
PATCH=
OUTPUT=-
MAXOFFSET=
JOURNAL=
TO=
readonly -a LEVELS=( emerg alert crit err warning notice info debug )
while getopts u:c:p:w:jm: arg; do case "$arg" in
u) LOGIN="$OPTARG" ;;
c) OUTPUT="${OPTARG:--}"; PATCH= ;;
p) OUTPUT="${OPTARG:--}"; PATCH=true ;;
w) MAXOFFSET="${OPTARG//[^0-9]}" ;;
j) JOURNAL=true ;;
m) TO="$OPTARG" ;;
esac; done
# $1: log level ∈ [0 … 7]
# $2: log text (“-” for STDIN)
function log() {
local l="$1"; shift
local m="$([ "$1" == - ] && cat || echo "$*")"
if [ -n "$m" ]; then
echo "$m" | \
[ -n "$JOURNAL" ] && systemd-cat -t epsi2ical -p ${LEVELS[$l]} || sed "s/^/[${LEVELS[$l]}] /" >&2
fi
}
function check_parameters_and_set_defaults() {
if [ -z "$LOGIN" ]; then
log 2 'No user provided (option -u)'
exit 1
fi
if [ "$OUTPUT" != - ] && ! [ \
-n "$OUTPUT" -a \
-d "$(dirname "$OUTPUT")" -a \
-w "$(dirname "$OUTPUT")" -a \
"$(basename "$OUTPUT")" != . -a \
"$(basename "$OUTPUT")" != .. \
]; then
log 2 "The directory (option -c or -p) “$(dirname "$OUTPUT")” is not writeable, or the filename is invalid"
exit 2
fi
if [ -n "$PATCH" -a "$OUTPUT" != - ] && ! [ -f "$OUTPUT" -a -r "$OUTPUT" -a -w "$OUTPUT" ]; then
log 2 "Patching (option -p) needs an existing r/w file; this one is not: $OUTPUT"
exit 3
fi
if [ -n "$JOURNAL" ] && ! which systemd-cat >/dev/null; then
JOURNAL=
log 4 "Command “systemd-cat” not found"
fi
if [ -n "$TO" ] && ! which sendmail >/dev/null; then
TO=
log 4 "Command “sendmail” not found"
fi
MAXOFFSET=$((${MAXOFFSET:-5}*7))
}
function fold_long_lines() {
env LANG="$LANG8BIT" sed -r 's/(.{73})(.)/\1\n \2/g'
}
function lf_to_crlf() {
sed 's/$/\r/'
}
function crlf_to_lf() {
tr -d '\r'
}
# &0: standard ical, with long lines wrapped
# &1: long lines are unwrapped
function unwrap_ical_long_lines() {
sed ':n;N;s/\n //;tn;P;D'
}
# $1: path to the old file
# $2: path to the new file
function format_diff_and_send_mail() {
icalChanges="$(diff -u8 \
<(crlf_to_lf <"$1" | unwrap_ical_long_lines | grep -vE '^DTSTAMP:|^UID:') \
<(crlf_to_lf <"$2" | unwrap_ical_long_lines | grep -vE '^DTSTAMP:|^UID:') \
| awk '
function out() {
if (inDiff+inEvent==2) printf("%s", diff)
inDiff=0
inEvent=0
}
/BEGIN:VEVENT/ { inEvent=1; diff="\n"; next }
$0 ~ /^[-+]/ { inDiff=1 }
/^@|END:VEVENT/ { out() }
{ diff=diff $0 "\n" }')"
if [ -n "$icalChanges" ]; then
{
echo "To: $TO"
echo 'Subject: Changes in EPSI calendar'
echo 'Content-Type: text/plain; charset=UTF-8'
echo
while IFS='' read -r l; do
case "$l" in
'')
echo; continue ;;
?DT*)
h="$(sed -r 's/^.DT(.*):.*/\1/' <<<"$l")"
v="$(date -d $(sed -r 's/.*:(....)(..)(..T..)(..)(..Z)/\1-\2-\3:\4:\5/' <<<"$l"))" ;;
*)
IFS=: read -r h v <<<"${l#?}" ;;
esac
printf '%s %11s: %s\n' "${l:0:1}" "$h" "$v"
done <<<"$icalChanges"
} \
| lf_to_crlf \
| sendmail -t 2>&1 \
| log 3 -
fi
}
function write_output_and_send_mail() {
[ "$OUTPUT" == - ] && cat || {
cat >"${OUTPUT}.tmp" \
&& {
[ -n "$TO" ] && format_diff_and_send_mail "$OUTPUT" "${OUTPUT}.tmp"
mv -f "${OUTPUT}.tmp" "$OUTPUT"
} \
|| {
log 3 "Failed to write to: $OUTPUT"
exit 20
}
}
}
# $1: string to encode
function urlencode() {
local saveLANG="$LANG"
export LANG="$LANG8BIT"
local index char
for (( index=0; index<${#1}; index++ )); do
char=${1:$index:1}
case "$char" in
[-A-Za-z0-9._~]) printf '%s' "$char" ;;
*) printf '%%%02x' "'$char" ;;
esac
done
export LANG="$saveLANG"
}
# $1: date in format %m/%d/%Y
function fetch_html_week() {
local url="https://edtmobiliteng.wigorservices.net/WebPsDyn.aspx?action=posEDTBEECOME&serverid=C&Tel=$(urlencode "$LOGIN")&date=$1"
for ((i=1; i<5; i=i*2)); do
week="$(curl -s "$url")" && { echo "$week"; sleep 0.3s; return; } || log 7 "Request “${url}” failed"
sleep ${i}s
done
log 3 "Cannot get week calendar from upstream for Month/Day/Year: $1"
exit 10
}
# &0: html week (see: fetch_html_week)
# $1: offset (±) from today, to locate the week
# $2: timestamp for this particular ical generation
function html_week_to_ical_week() {
local offsetDayOfWeek=$(date +%u -d "${1}days") # ∈ [1=monday … 5=friday]
local mon=$(date +%Y%m%dT%z -d "$((${1}+1-$offsetDayOfWeek))days")
local tue=$(date +%Y%m%dT%z -d "$((${1}+2-$offsetDayOfWeek))days")
local wed=$(date +%Y%m%dT%z -d "$((${1}+3-$offsetDayOfWeek))days")
local thu=$(date +%Y%m%dT%z -d "$((${1}+4-$offsetDayOfWeek))days")
local fri=$(date +%Y%m%dT%z -d "$((${1}+5-$offsetDayOfWeek))days")
tr '\r\n' ' ' \
| sed -r 's/[[:blank:]]+/ /g' \
| grep -oE '<DIV [^>]*class="Case" [^>]*style="[^"]*left *:[^"]*"|DIV [^>]*style="[^"]*left *:[^"]*" [^>]*class="Case"|<td [^>]*class="TC(ase|Prof|hdeb|Salle)"([^<]|<[^/]|</[^t]|</t[^d])*</td>' \
| awk -vmon=$mon -vtue=$tue -vwed=$wed -vthu=$thu -vfri=$fri -vid="epsi2ics/${LOGIN}@${HOSTNAME}" -vdtstamp=$2 -F$'\t' '
function out() {
if (from!="" && to!="" && ase!="") printf( \
"BEGIN:VEVENT\nUID:%s/%s/%s\nDTSTAMP:%s\nDTSTART:%s\nDTEND:%s\nSUMMARY:%s\nLOCATION:%s\nDESCRIPTION:🗣 %s 👥 %s\nEND:VEVENT\n", \
from, id, prof, dtstamp, from, to, ase, where, prof, who)
from=""
to=""
ase=""
where=""
prof=""
who=""
day=""
zoneH=0
zoneM=0
}
function toZtime(localH, localM) {
localM-=zoneM
if (localM<0) {
localM+=60
localH-=1
} else if (localM>59) {
localM-=60
localH+=1
}
localH-=zoneH
return sprintf("%s%02d%02d00Z", day, localH, localM)
}
/class="Case"/ {
out()
pc=gensub(".*left *: *([0-9]+)[%.].*", "\\1", 1)
if (pc < 115) tmpd=mon
else if (pc < 135) tmpd=tue
else if (pc < 155) tmpd=wed
else if (pc < 175) tmpd=thu
else tmpd=fri
day=substr(tmpd, 1, 9)
zoneH=substr(tmpd, 10, 3)+0
zoneM=(substr(tmpd, 10, 1) substr(tmpd, 13, 2))+0
}
/class="TCase"/ {
ase=gensub(".*</div>(.*)</.*", "\\1", 1)
}
/class="TCProf"/ {
split(gensub(".*>(.*)<br/?>(.*)</.*", "\\1\t\\2", 1), tmp)
prof=tmp[1]
who=tmp[2]
}
/class="TChdeb"/ {
split(gensub(".*> *0?([0-9]+):0?([0-9]+) *- *0?([0-9]+):0?([0-9]+) *</.*", "\\1\t\\2\t\\3\t\\4", 1), tmp)
from=toZtime(tmp[1], tmp[2])
to=toZtime(tmp[3], tmp[4])
}
/class="TCSalle"/ {
where=gensub(".*>(Salle:)?(.*)</.*", "\\2", 1)
}
END {
out()
}'
}
function output_ical_header() {
cat <<-ENDOFTEXT
BEGIN:VCALENDAR
VERSION:2.0
PRODID:yalis.fr/epsi2ical v1.1
ENDOFTEXT
}
function output_ical_footer() {
cat <<-ENDOFTEXT
END:VCALENDAR
ENDOFTEXT
}
# $1: offset (±) from today, to locate the week
# $2: timestamp for this particular ical generation
function output_ical_week {
fetch_html_week $(date +%m/%d/%Y -d "${1}days") \
| html_week_to_ical_week "$1" "$2"
}
# $1: offset (±) from today, to locate the week
function dump_known_ical_week {
local offsetDayOfWeek=$(date +%u -d "${1}days") # ∈ [1=monday … 5=friday]
local mon=$(date +%Y%m%d -d "$((${1}+1-$offsetDayOfWeek))days")
local tue=$(date +%Y%m%d -d "$((${1}+2-$offsetDayOfWeek))days")
local wed=$(date +%Y%m%d -d "$((${1}+3-$offsetDayOfWeek))days")
local thu=$(date +%Y%m%d -d "$((${1}+4-$offsetDayOfWeek))days")
local fri=$(date +%Y%m%d -d "$((${1}+5-$offsetDayOfWeek))days")
tr -d '\r' <"$OUTPUT" \
| awk -v week="^DTSTART:($mon|$tue|$wed|$thu|$fri)" '
function out() {
if (inWeek==1) {
printf("%s", day)
inWeek=0
}
}
/BEGIN:VEVENT/ { day=$0 "\n"; next }
{ day=day $0 "\n" }
$0 ~ week { inWeek=1 }
/END:VEVENT/ { out() }
'
}
# &1: first offset of the school-year, and max offset of the same school-year, space-separated
function get_first_and_max_offset_of_year() {
local currentYear=$(date +%Y)
local splitDay=$(date +%j -d "${currentYear}-08-01" | sed 's/^0*//') # XXXX-08-01 ∈ [1 … 366]
local dayOfYear=$(date +%j | sed 's/^0*//') # this day ∈ [1 … 366]
local offset= offsetMax=
if [ $dayOfYear -lt $splitDay ]; then
offsetMax=$(( splitDay-dayOfYear ))
offset=$(( offsetMax-365 ))
else
offset=$(( splitDay-dayOfYear ))
offsetMax=$(( 365+offset ))
fi
printf '%s %s\n' $offset $offsetMax
}
# $1: offset (±) from today, to locate the week
# exit status: 0 if $1 is in the current week
function is_current_week() {
local offsetWeek=$(date +%G%V -d "${1}days")
local currentWeek=$(date +%G%V)
[ $offsetWeek -eq $currentWeek ]
}
function main() {
check_parameters_and_set_defaults
{
local dtstamp=$(date -u +%Y%m%dT%H%M%SZ)
local offset= offsetMax=
local step=-1 # -1: before patching; 0: patching time-span; 1: after patching
read -r offset offsetMax < <(get_first_and_max_offset_of_year)
output_ical_header
while [ $offset -lt $offsetMax ]; do
if [ -n "$PATCH" ]; then
case $step in
-1)
if is_current_week $offset; then
MAXOFFSET=$((MAXOFFSET+offset))
step=0
continue
fi
dump_known_ical_week $offset ;;
0)
if [ $offset -ge $MAXOFFSET ]; then
step=1
continue
fi
output_ical_week $offset "$dtstamp" ;;
1)
dump_known_ical_week $offset ;;
esac
else
output_ical_week $offset "$dtstamp"
fi
offset=$(($offset+7))
done
output_ical_footer
} \
| fold_long_lines \
| lf_to_crlf \
| write_output_and_send_mail
}
main