From d26164fb725e22f8eebd8d94ad59cab3921b8a6f Mon Sep 17 00:00:00 2001 From: vxid Date: Sat, 2 Mar 2019 16:12:15 +0100 Subject: Add support for vdir synchronization Add a script to synchronize calcurse with a VDIR collection. Add a wrapper script around vdirsyncer to automatically synchronize calcurse data to a vdirsyncer collection. Add script documentation and Makefile. Signed-off-by: Lukas Fleischer --- Makefile.am | 2 +- configure.ac | 5 +- contrib/vdir/Makefile.am | 13 +++ contrib/vdir/README.md | 58 ++++++++++++ contrib/vdir/calcurse-vdir.py | 186 +++++++++++++++++++++++++++++++++++++++ contrib/vdir/calcurse-vdirsyncer | 71 +++++++++++++++ 6 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 contrib/vdir/Makefile.am create mode 100644 contrib/vdir/README.md create mode 100755 contrib/vdir/calcurse-vdir.py create mode 100755 contrib/vdir/calcurse-vdirsyncer diff --git a/Makefile.am b/Makefile.am index fb428c1..e470777 100644 --- a/Makefile.am +++ b/Makefile.am @@ -2,7 +2,7 @@ AUTOMAKE_OPTIONS= foreign ACLOCAL_AMFLAGS = -I m4 -SUBDIRS = po src test scripts contrib/caldav +SUBDIRS = po src test scripts contrib/caldav contrib/vdir if ENABLE_DOCS SUBDIRS += doc diff --git a/configure.ac b/configure.ac index c7db268..1f6ec61 100644 --- a/configure.ac +++ b/configure.ac @@ -152,8 +152,9 @@ AM_CONDITIONAL(CALCURSE_MEMORY_DEBUG, test x$memdebug = xyes) #------------------------------------------------------------------------------- # Create Makefiles #------------------------------------------------------------------------------- -AC_OUTPUT(Makefile doc/Makefile src/Makefile test/Makefile scripts/Makefile \ - po/Makefile.in po/Makefile contrib/caldav/Makefile) +AC_OUTPUT(Makefile doc/Makefile src/Makefile test/Makefile \ + scripts/Makefile po/Makefile.in po/Makefile \ + contrib/caldav/Makefile contrib/vdir/Makefile) #------------------------------------------------------------------------------- # Summary #------------------------------------------------------------------------------- diff --git a/contrib/vdir/Makefile.am b/contrib/vdir/Makefile.am new file mode 100644 index 0000000..2bb7f0b --- /dev/null +++ b/contrib/vdir/Makefile.am @@ -0,0 +1,13 @@ +AUTOMAKE_OPTIONS = foreign + +dist_bin_SCRIPTS = \ + calcurse-vdir + +EXTRA_DIST = \ + calcurse-vdir.py + +CLEANFILES = \ + calcurse-vdir + +calcurse-vdir: calcurse-vdir.py + cp "$(srcdir)/$<" "$@" diff --git a/contrib/vdir/README.md b/contrib/vdir/README.md new file mode 100644 index 0000000..b040d04 --- /dev/null +++ b/contrib/vdir/README.md @@ -0,0 +1,58 @@ +calcurse-vdir +=============== + +calcurse-vdir is a Python script designed to export and import data to and +from directories following the +[vdir](http://vdirsyncer.pimutils.org/en/stable/vdir.html) storage format. +This data can then be synced with various remotes using tools like +[vdirsyncer](https://github.com/pimutils/vdirsyncer). +Please note that the script is alpha software! This means that: + +* We are eagerly looking for testers to run the script and give feedback! If + you find any bugs, please report them to the calcurse mailing lists or to the + GitHub bug tracker. If the script works fine for you, please report back as + well! + +* The script might still have bugs. MAKE BACKUPS, especially before running + calcurse-vdir with the `-f` flag! + +Usage +----- + +calcurse-vdir requires an up-to-date version of calcurse and python. +To run calcurse-vdir, call the script using + +```sh +calcurse-vdir +``` + +where `action` is either `import` or `export` and where `vdir` is the local +directory to interact with. + +When importing events, calcurse-vdir imports every event found in the vdir +directory that is not also present in calcurse. When exporting events, +calcurse-vdir does the opposite and writes any new event to the vdir directory. + +These operations are non-destructive by default, meaning that no event will be +deleted by the script. The `-f` flag can be used to make the origin mirror the +destination, potentially deleting events in the destination that are no longer +present in the origin. + +You can optionally specify an alternative directory for local calcurse data +using the `-D` flag if it differs from the default `~/.calcurse`. + +Integration with vdirsyncer +--------------------------- + +A vdirsyncer synchronisation script `calcurse-vdirsyncer` is can be found in +the `contrib` directory. This script wraps event export, vdirsyncer +synchronization and imports in a single call. Run `calcurse-vdirsyncer -h` for +more information. + +Planned Updates +--------------- + +* Support for hook directories +* Enable filtering of imported and exported items (events, todos) +* Improve event parsing robustness +* Add testing support diff --git a/contrib/vdir/calcurse-vdir.py b/contrib/vdir/calcurse-vdir.py new file mode 100755 index 0000000..cf65fcb --- /dev/null +++ b/contrib/vdir/calcurse-vdir.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +import argparse +import io +import os +import re +import subprocess +import sys +import textwrap + + +def msgfmt(msg, prefix=''): + """Format a message""" + lines = [] + for line in msg.splitlines(): + lines += textwrap.wrap(line, 80 - len(prefix)) + return '\n'.join([prefix + line for line in lines]) + + +def log(msg): + """Print a formatted message""" + print(msgfmt(msg)) + + +def die(msg): + """Exit on error""" + sys.exit(msgfmt(msg, prefix="error: ")) + + +def check_binary(binary): + """Check if a binary is available in $PATH""" + try: + subprocess.call([binary, '--version'], stdout=subprocess.DEVNULL) + except FileNotFoundError: + die("{0} is not available.".format(binary)) + + +def check_directory(directory): + """Check if a directory exists""" + if not os.path.isdir(directory): + die("invalid directory: {0}".format(directory)) + + +def file_to_uid(file): + """Return the uid of an ical file""" + uid = file.replace(vdir, "").replace(".ics", "") + return uid + + +def write_file(file, contents): + """Write to file""" + if verbose: + log("Writing event {0}".format(file_to_uid(file))) + with open(file, 'w') as f: + f.write(contents) + + +def remove_file(file): + """Remove file""" + if verbose: + log("Deleting event {0}".format(file_to_uid(file))) + if os.path.isfile(file): + os.remove(file) + + +def calcurse_export(): + """Return raw calcurse data""" + command = calcurse + ['-xical', '--export-uid'] + proc = subprocess.Popen(command, stdout=subprocess.PIPE) + return [x for x in io.TextIOWrapper(proc.stdout, encoding="utf-8")] + + +def calcurse_remove(uid): + """Remove calcurse event by uid""" + if verbose: + log("Removing event {0} from calcurse".format(uid)) + command = calcurse + ['-P', '--filter-hash=' + uid] + subprocess.call(command) + + +def calcurse_import(file): + """Import ics file to calcurse""" + if verbose: + log("Importing event {0} to calcurse".format(file_to_uid(file))) + command = calcurse + ['-i', file] + subprocess.call(command, stdout=subprocess.DEVNULL) + + +def calcurse_list(): + """Return all calcurse item uids""" + command = calcurse + [ + '-G', + '--format-apt=%(hash)\\n', + '--format-recur-apt=%(hash)\\n', + '--format-event=%(hash)\\n', + '--format-recur-event=%(hash)\\n', + '--format-todo=%(hash)\\n' + ] + proc = subprocess.Popen(command, stdout=subprocess.PIPE) + return [x.strip() for x in io.TextIOWrapper(proc.stdout, encoding="utf-8")] + + +def parse_calcurse_data(raw): + """Parse raw calcurse data to a uid/ical dictionary""" + + header = ''.join(raw[:3]) + regex = '(BEGIN:(VEVENT|VTODO).*?END:(VEVENT|VTODO).)' + events = [x[0] for x in re.findall(regex, ''.join(raw), re.DOTALL)] + + items = {} + + for item in events: + uid = re.findall('UID:(.*?)\n', item)[0] + items[uid] = header + item + "END:VCALENDAR\n" + + return items + + +def calcurse_to_vdir(): + """Export calcurse data to vdir""" + raw_events = calcurse_export() + events = parse_calcurse_data(raw_events) + + files_vdir = [x for x in os.listdir(vdir)] + files_calc = [uid + ".ics" for uid in events] + + if force: + for file in [f for f in files_vdir if f not in files_calc]: + remove_file(os.path.join(vdir, file)) + + for uid, event in events.items(): + file = uid + ".ics" + if file not in files_vdir: + write_file(os.path.join(vdir, file), event) + + +def vdir_to_calcurse(): + """Import vdir data to calcurse""" + files_calc = [x + '.ics' for x in calcurse_list()] + files_vdir = [x for x in os.listdir(vdir) if x.endswith('.ics')] + + for file in [f for f in files_vdir if f not in files_calc]: + calcurse_import(os.path.join(vdir, file)) + + if force: + for file in [f for f in files_calc if f not in files_vdir]: + calcurse_remove(file[:-4]) + + +parser = argparse.ArgumentParser('calcurse-vdir') +parser.add_argument('action', choices=['import', 'export'], + help='export or import calcurse data') +parser.add_argument('vdir', + help='path to the vdir collection directory') +parser.add_argument('-D', '--datadir', action='store', dest='datadir', + default=None, + help='path to the calcurse data directory') +parser.add_argument('-f', '--force', action='store_true', dest='force', + default=False, + help='enable destructive import and export') +parser.add_argument('-v', '--verbose', action='store_true', dest='verbose', + default=False, + help='print status messages to stdout') +args = parser.parse_args() + +action = args.action +datadir = args.datadir +force = args.force +verbose = args.verbose +vdir = args.vdir + +check_directory(vdir) + +check_binary('calcurse') +calcurse = ['calcurse'] + +if datadir: + check_directory(datadir) + calcurse += ['-D', datadir] + +if action == 'import': + vdir_to_calcurse() +elif action == 'export': + calcurse_to_vdir() +else: + die("Invalid action {0}.".format(action)) diff --git a/contrib/vdir/calcurse-vdirsyncer b/contrib/vdir/calcurse-vdirsyncer new file mode 100755 index 0000000..c5371b5 --- /dev/null +++ b/contrib/vdir/calcurse-vdirsyncer @@ -0,0 +1,71 @@ +#!/bin/sh + +set -e + +usage() { + echo "usage: calcurse-vdirsyncer vdir [-h] [-f] [-v] [-D] datadir" + exit +} + +set_vdir() { + if [ ! -d "$1" ]; then + echo "error: $1 is not a valid vdir directory." + exit 1 + else + VDIR="$1" + fi +} + +set_datadir() { + if [ -z "$1" ]; then + echo "error: no datadir specified." + usage + fi + if [ ! -d "$1" ]; then + echo "error: $1 is not a valid data directory." + exit 1 + else + DATADIR="$1" + shift + fi +} + +if [ "$#" -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + usage +fi + +DATADIR="$HOME/.calcurse" +VERBOSE="" +FORCE="" + +set_vdir "$1" +shift + +while [ $# -gt 0 ]; do + case "$1" in + -D|--datadir) + shift + set_datadir "$1" + shift + ;; + -h|--help) + usage + ;; + -f|--force) + FORCE="-f" + shift + ;; + -v|--verbose) + VERBOSE="-v" + shift + ;; + *) + echo "error: invalid argument $1" + usage + ;; + esac +done + +calcurse-vdir export "$VDIR" -D "$DATADIR" "$FORCE" "$VERBOSE" && \ + vdirsyncer sync && \ + calcurse-vdir import "$VDIR" -D "$DATADIR" "$FORCE" "$VERBOSE" -- cgit v1.2.3-54-g00ecf