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 <lfleischer@calcurse.org>
This commit is contained in:
vxid 2019-03-02 16:12:15 +01:00 committed by Lukas Fleischer
parent 5eff08777b
commit d26164fb72
6 changed files with 332 additions and 3 deletions

View File

@ -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

View File

@ -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
#-------------------------------------------------------------------------------

13
contrib/vdir/Makefile.am Normal file
View File

@ -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)/$<" "$@"

58
contrib/vdir/README.md Normal file
View File

@ -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 <action> <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

186
contrib/vdir/calcurse-vdir.py Executable file
View File

@ -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))

View File

@ -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"