aboutsummaryrefslogtreecommitdiffstats
path: root/contrib
diff options
context:
space:
mode:
Diffstat (limited to 'contrib')
-rw-r--r--contrib/caldav/README.md13
-rwxr-xr-xcontrib/caldav/calcurse-caldav.py277
-rw-r--r--contrib/caldav/config.sample14
-rwxr-xr-xcontrib/caldav/hooks/post-sync2
-rw-r--r--contrib/import/DST-et.ical11
-rw-r--r--contrib/import/DST-eu.ical11
-rw-r--r--contrib/import/February.ical11
-rw-r--r--contrib/import/Monday-first-last.ical11
-rw-r--r--contrib/import/Tuesday-Thursday.ical11
-rw-r--r--contrib/import/Wednesdays-all.ical11
-rw-r--r--contrib/setup.cfg3
11 files changed, 239 insertions, 136 deletions
diff --git a/contrib/caldav/README.md b/contrib/caldav/README.md
index a842081..b464dc9 100644
--- a/contrib/caldav/README.md
+++ b/contrib/caldav/README.md
@@ -34,13 +34,12 @@ argument. You can choose between the following initialization modes:
For subsequent calcurse-caldav invocations, you don't need to specify any
additional parameters.
-You can specify a username and password for basic authentication in the
-config file. Alternatively, the password can be passed securely from another
-program (such as *pass*) via the `CALCURSE_CALDAV_PASSWORD` environment variable like
-so:
-```
-CALCURSE_CALDAV_PASSWORD=$(pass show calcurse) calcurse-caldav
-```
+Specify your HTTP Basic authentication credentials under the config file's
+`Auth` section. The most secure approach is to save your password in a CLI
+encrypted password store (_e.g.,_ [pass](https://www.passwordstore.org/)), and
+then set `PasswordCommand` to the shell command used to retrieve it.
+If security is not a priority, you may store your password in plain text
+instead.
Hooks
-----
diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py
index beea376..f9488e6 100755
--- a/contrib/caldav/calcurse-caldav.py
+++ b/contrib/caldav/calcurse-caldav.py
@@ -3,24 +3,92 @@
import argparse
import base64
import configparser
-import httplib2
import os
+import pathlib
import re
+import shlex
import subprocess
import sys
import textwrap
-import urllib.parse
import xml.etree.ElementTree as etree
+import httplib2
+
# Optional libraries for OAuth2 authentication
try:
- from oauth2client.client import OAuth2WebServerFlow, HttpAccessTokenRefreshError
- from oauth2client.file import Storage
import webbrowser
+
+ from oauth2client.client import HttpAccessTokenRefreshError, OAuth2WebServerFlow
+ from oauth2client.file import Storage
except ModuleNotFoundError:
pass
+class Config:
+ _map = {}
+
+ def __init__(self, fn):
+ self._map = {
+ 'Auth': {
+ 'Password': None,
+ 'PasswordCommand': None,
+ 'Username': None,
+ },
+ 'CustomHeaders': {},
+ 'General': {
+ 'AuthMethod': 'basic',
+ 'Binary': 'calcurse',
+ 'Debug': False,
+ 'DryRun': True,
+ 'HTTPS': True,
+ 'Hostname': None,
+ 'InsecureSSL': False,
+ 'Path': None,
+ 'SyncFilter': 'cal,todo',
+ 'Verbose': False,
+ },
+ 'OAuth2': {
+ 'ClientID': None,
+ 'ClientSecret': None,
+ 'RedirectURI': 'http://127.0.0.1',
+ 'Scope': None,
+ },
+ }
+
+ config = configparser.RawConfigParser()
+ config.optionxform = str
+ if verbose:
+ print('Loading configuration from ' + configfn + '...')
+ try:
+ config.read_file(open(fn))
+ except FileNotFoundError:
+ die('Configuration file not found: {}'.format(fn))
+
+ for sec in config.sections():
+ if sec not in self._map:
+ die('Unexpected config section: {}'.format(sec))
+
+ if not self._map[sec]:
+ # Import section with custom key-value pairs.
+ self._map[sec] = dict(config.items(sec))
+ continue
+
+ # Import section with predefined keys.
+ for key, val in config.items(sec):
+ if key not in self._map[sec]:
+ die('Unexpected config key in section {}: {}'.format(sec, key))
+ if type(self._map[sec][key]) == bool:
+ self._map[sec][key] = config.getboolean(sec, key)
+ else:
+ self._map[sec][key] = val
+
+ def section(self, section):
+ return self._map[section]
+
+ def get(self, section, key):
+ return self._map[section][key]
+
+
def msgfmt(msg, prefix=''):
lines = []
for line in msg.splitlines():
@@ -37,8 +105,10 @@ def die(msg):
def check_dir(dir):
- if not os.path.isdir(dir):
- die("invalid directory: {0}".format(dir))
+ try:
+ pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
+ except FileExistsError:
+ die("{} is not a directory".format(dir))
def die_atnode(msg, node):
@@ -145,8 +215,8 @@ def calcurse_version():
def get_auth_headers():
if not username or not password:
return {}
- user_password = ('{}:{}'.format(username, password)).encode('ascii')
- user_password = base64.b64encode(user_password).decode('ascii')
+ user_password = ('{}:{}'.format(username, password)).encode('utf-8')
+ user_password = base64.b64encode(user_password).decode('utf-8')
headers = {'Authorization': 'Basic {}'.format(user_password)}
return headers
@@ -351,18 +421,21 @@ def push_object(conn, objhash):
if not headers:
return None
-
- etag = None
headerdict = dict(headers)
- if 'etag' in headerdict:
- etag = headerdict['etag']
- while not etag:
+
+ # Retrieve href from server to match server-side format. Retrieve ETag
+ # unless it can be extracted from the PUT response already.
+ ret_href, ret_etag = None, headerdict.get('etag')
+ while not ret_etag or not ret_href:
etagdict = get_etags(conn, [href])
- if etagdict:
- etag = next(iter(etagdict.values()))
- etag = etag.strip('"')
+ if not etagdict:
+ continue
+ ret_href, new_etag = next(iter(etagdict.items()))
+ # Favor ETag from PUT response to avoid race condition.
+ if not ret_etag:
+ ret_etag = new_etag
- return (urllib.parse.quote(href), etag)
+ return (ret_href, ret_etag.strip('"'))
def push_objects(objhashes, conn, syncdb, etagdict):
@@ -520,20 +593,28 @@ nsmap = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
# Initialize default values.
if os.path.isdir(os.path.expanduser("~/.calcurse")):
- configfn = os.path.expanduser("~/.calcurse/caldav/config")
- lockfn = os.path.expanduser("~/.calcurse/caldav/lock")
- syncdbfn = os.path.expanduser("~/.calcurse/caldav/sync.db")
- hookdir = os.path.expanduser("~/.calcurse/caldav/hooks/")
- oauth_file = os.path.expanduser("~/.calcurse/caldav/oauth2_cred")
+ caldav_path = os.path.expanduser("~/.calcurse/caldav")
+ check_dir(caldav_path)
+
+ configfn = os.path.join(caldav_path, "config")
+ hookdir = os.path.join(caldav_path, "hooks")
+ oauth_file = os.path.join(caldav_path, "oauth2_cred")
+ lockfn = os.path.join(caldav_path, "lock")
+ syncdbfn = os.path.join(caldav_path, "sync.db")
else:
- calcurse_data = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) + "/calcurse"
- calcurse_config = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + "/calcurse"
+ xdg_config_home = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
+ xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
+ caldav_config = os.path.join(xdg_config_home, "calcurse", "caldav")
+ caldav_data = os.path.join(xdg_data_home, "calcurse", "caldav")
+ check_dir(caldav_config)
+ check_dir(caldav_data)
- configfn = os.path.expanduser(calcurse_config + "/caldav/config")
- lockfn = os.path.expanduser(calcurse_data + "/caldav/lock")
- syncdbfn = os.path.expanduser(calcurse_data + "/caldav/sync.db")
- hookdir = os.path.expanduser(calcurse_config + "/caldav/hooks/")
- oauth_file = os.path.expanduser(calcurse_config + "/caldav/oauth2_cred")
+ configfn = os.path.join(caldav_config, "config")
+ hookdir = os.path.join(caldav_config, "hooks")
+ oauth_file = os.path.join(caldav_config, "oauth2_cred")
+
+ lockfn = os.path.join(caldav_data, "lock")
+ syncdbfn = os.path.join(caldav_data, "sync.db")
# Parse command line arguments.
parser = argparse.ArgumentParser('calcurse-caldav')
@@ -578,105 +659,57 @@ verbose = args.verbose
debug = args.debug
debug_raw = args.debug_raw
-# Read environment variables
-password = os.getenv('CALCURSE_CALDAV_PASSWORD')
-
# Read configuration.
-config = configparser.RawConfigParser()
-if verbose:
- print('Loading configuration from ' + configfn + '...')
-try:
- config.read_file(open(configfn))
-except FileNotFoundError as e:
- die('Configuration file not found: {}'.format(configfn))
-
-if config.has_option('General', 'InsecureSSL'):
- insecure_ssl = config.getboolean('General', 'InsecureSSL')
+config = Config(configfn)
+
+authmethod = config.get('General', 'AuthMethod').lower()
+calcurse = [config.get('General', 'Binary')]
+debug = debug or config.get('General', 'Debug')
+dry_run = config.get('General', 'DryRun')
+hostname = config.get('General', 'Hostname')
+https = config.get('General', 'HTTPS')
+insecure_ssl = config.get('General', 'InsecureSSL')
+path = config.get('General', 'Path')
+sync_filter = config.get('General', 'SyncFilter')
+verbose = verbose or config.get('General', 'Verbose')
+
+if os.getenv('CALCURSE_CALDAV_PASSWORD'):
+ # This approach is deprecated, but preserved for backwards compatibility
+ password = os.getenv('CALCURSE_CALDAV_PASSWORD')
+elif config.get('Auth', 'Password'):
+ password = config.get('Auth', 'Password')
+elif config.get('Auth', 'PasswordCommand'):
+ tokenized_cmd = shlex.split(config.get('Auth', 'PasswordCommand'))
+ password = subprocess.run(tokenized_cmd, capture_output=True).stdout.decode('UTF-8')
else:
- insecure_ssl = False
+ password = None
-# Read config for "HTTPS" option (default=True)
-if config.has_option('General', 'HTTPS'):
- https = config.getboolean('General', 'HTTPS')
-else:
- https = True
+username = config.get('Auth', 'Username')
-if config.has_option('General', 'Binary'):
- calcurse = [config.get('General', 'Binary')]
-else:
- calcurse = ['calcurse']
+client_id = config.get('OAuth2', 'ClientID')
+client_secret = config.get('OAuth2', 'ClientSecret')
+redirect_uri = config.get('OAuth2', 'RedirectURI')
+scope = config.get('OAuth2', 'Scope')
+
+custom_headers = config.section('CustomHeaders')
+# Append data directory to calcurse command.
if datadir:
check_dir(datadir)
calcurse += ['-D', datadir]
-if config.has_option('General', 'DryRun'):
- dry_run = config.getboolean('General', 'DryRun')
-else:
- dry_run = True
-
-if not verbose and config.has_option('General', 'Verbose'):
- verbose = config.getboolean('General', 'Verbose')
-
-if not debug and config.has_option('General', 'Debug'):
- debug = config.getboolean('General', 'Debug')
-
-if config.has_option('General', 'AuthMethod'):
- authmethod = config.get('General', 'AuthMethod').lower()
-else:
- authmethod = 'basic'
-
-if config.has_option('General', 'SyncFilter'):
- sync_filter = config.get('General', 'SyncFilter')
-
- invalid_filter_values = validate_sync_filter()
-
- if len(invalid_filter_values):
- die('Invalid value(s) in SyncFilter option: ' + ', '.join(invalid_filter_values))
-else:
- sync_filter = 'cal,todo'
-
-if config.has_option('Auth', 'UserName'):
- username = config.get('Auth', 'UserName')
-else:
- username = None
-
-if config.has_option('Auth', 'Password') and not password:
- password = config.get('Auth', 'Password')
-
-if config.has_section('CustomHeaders'):
- custom_headers = dict(config.items('CustomHeaders'))
-else:
- custom_headers = {}
-
-if config.has_option('OAuth2', 'ClientID'):
- client_id = config.get('OAuth2', 'ClientID')
-else:
- client_id = None
-
-if config.has_option('OAuth2', 'ClientSecret'):
- client_secret = config.get('OAuth2', 'ClientSecret')
-else:
- client_secret = None
-
-if config.has_option('OAuth2', 'Scope'):
- scope = config.get('OAuth2', 'Scope')
-else:
- scope = None
-
-if config.has_option('OAuth2', 'RedirectURI'):
- redirect_uri = config.get('OAuth2', 'RedirectURI')
-else:
- redirect_uri = 'http://127.0.0.1'
-
-# Change URl prefix according to HTTP/HTTPS
-if https:
- urlprefix = "https://"
-else:
- urlprefix = "http://"
-
-hostname = config.get('General', 'HostName')
-path = '/' + config.get('General', 'Path').strip('/') + '/'
+# Validate sync filter.
+invalid_filter_values = validate_sync_filter()
+if len(invalid_filter_values):
+ die('Invalid value(s) in SyncFilter option: ' + ', '.join(invalid_filter_values))
+
+# Ensure host name and path are defined and initialize *_uri.
+if not hostname:
+ die('Hostname missing in configuration.')
+if not path:
+ die('Path missing in configuration.')
+urlprefix = "https://" if https else "http://"
+path = '/{}/'.format(path.strip('/'))
hostname_uri = urlprefix + hostname
absolute_uri = hostname_uri + path
@@ -709,9 +742,7 @@ try:
# Connect to the server.
if verbose:
print('Connecting to ' + hostname + '...')
- conn = httplib2.Http()
- if insecure_ssl:
- conn.disable_ssl_certificate_validation = True
+ conn = httplib2.Http(disable_ssl_certificate_validation=insecure_ssl)
if authmethod == 'oauth2':
# Authenticate with OAuth2 and authorize HTTP object
@@ -774,7 +805,7 @@ try:
# Write the synchronization database.
save_syncdb(syncdbfn, syncdb)
- #Clear OAuth2 credentials if used
+ # Clear OAuth2 credentials if used.
if authmethod == 'oauth2':
conn.clear_credentials()
diff --git a/contrib/caldav/config.sample b/contrib/caldav/config.sample
index c89cfb7..0ba8fa8 100644
--- a/contrib/caldav/config.sample
+++ b/contrib/caldav/config.sample
@@ -11,10 +11,12 @@
# Path to the calcurse binary that is used for importing/exporting items.
Binary = calcurse
-# Host name of the server that hosts CalDAV.
+# Host name of the server that hosts CalDAV. Do NOT prepend a protocol prefix,
+# such as http:// or https://. Append :<port> for a port other than 80.
Hostname = some.hostname.com
-# Path to the CalDAV calendar on the host specified above.
+# Path to the CalDAV calendar on the host specified above. This is the base
+# path following your host name in the URL.
Path = /path/to/calendar/on/the/server/
# Type of authentication to use. Must be "basic" or "oauth2"
@@ -46,11 +48,13 @@ DryRun = Yes
# Enable this if you want detailed logs written to stdout.
Verbose = Yes
-# Credentials for HTTP Basic Authentication. Leave this commented out if you do
-# not want to use authentication.
+# Credentials for HTTP Basic Authentication (if required).
+# Set `Password` to your password in plaintext (unsafe),
+# or `PasswordCommand` to a shell command that retrieves it (recommended).
#[Auth]
#Username = user
-#Password = pass
+#Password = password
+#PasswordCommand = pass baikal
# Optionally specify additional HTTP headers here.
#[CustomHeaders]
diff --git a/contrib/caldav/hooks/post-sync b/contrib/caldav/hooks/post-sync
index 62ebe11..db0059b 100755
--- a/contrib/caldav/hooks/post-sync
+++ b/contrib/caldav/hooks/post-sync
@@ -26,7 +26,7 @@ commit_dir() {
if [ -d .git ] && command -v git >/dev/null; then
git add "$@"
if ! git diff-index --quiet --cached HEAD; then
- git commit -m "Automatic commit by the post-save hook"
+ git commit -m "Automatic commit by the post-sync hook"
fi
fi
}
diff --git a/contrib/import/DST-et.ical b/contrib/import/DST-et.ical
new file mode 100644
index 0000000..116fe95
--- /dev/null
+++ b/contrib/import/DST-et.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART;VALUE=DATE:20200329
+SUMMARY:Daylight Saving Time begins (EDT)
+RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/import/DST-eu.ical b/contrib/import/DST-eu.ical
new file mode 100644
index 0000000..e536cf0
--- /dev/null
+++ b/contrib/import/DST-eu.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART;VALUE=DATE:20200329
+SUMMARY:Daylight Saving Time begins (CEST)
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/import/February.ical b/contrib/import/February.ical
new file mode 100644
index 0000000..15168d4
--- /dev/null
+++ b/contrib/import/February.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART:20200228T080000
+SUMMARY:Penultimate day in February
+RRULE:FREQ=MONTHLY;BYMONTHDAY=-2;BYMONTH=2
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/import/Monday-first-last.ical b/contrib/import/Monday-first-last.ical
new file mode 100644
index 0000000..431bebb
--- /dev/null
+++ b/contrib/import/Monday-first-last.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART:20200106T120000
+SUMMARY:First and last Monday of the month
+RRULE:FREQ=MONTHLY;BYDAY=1MO,-1MO;COUNT=10
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/import/Tuesday-Thursday.ical b/contrib/import/Tuesday-Thursday.ical
new file mode 100644
index 0000000..84c5adb
--- /dev/null
+++ b/contrib/import/Tuesday-Thursday.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART:20200102T120000
+SUMMARY:Every Tuesday and Thursday
+RRULE:FREQ=WEEKLY;BYDAY=TU,TH;COUNT=10
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/import/Wednesdays-all.ical b/contrib/import/Wednesdays-all.ical
new file mode 100644
index 0000000..40730b8
--- /dev/null
+++ b/contrib/import/Wednesdays-all.ical
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:
+BEGIN:VEVENT
+DTSTAMP:
+UID:
+DTSTART;VALUE=DATE:20200603
+SUMMARY:All Wednesdays in June and July
+RRULE:FREQ=MONTHLY;BYDAY=WE;BYMONTH=6,7;UNTIL=20220630
+END:VEVENT
+END:VCALENDAR
diff --git a/contrib/setup.cfg b/contrib/setup.cfg
new file mode 100644
index 0000000..0691fee
--- /dev/null
+++ b/contrib/setup.cfg
@@ -0,0 +1,3 @@
+[flake8]
+max-line-length = 100
+max-complexity = 16