diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..9641d7e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2013 Wakatime +https://wakatime.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 9c5d7d4..1384f32 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,20 @@ -vim-wakatime -============ +vim-wakatime 0.1.2 +=========== + +Automatic time tracking. + +Installation +------------ + +Get an api key by signing up at https://www.wakati.me + +Using [Vundle](https://github.com/gmarik/vundle), the Vim plugin manager: + +Add `Bundle 'wakatime/vim-wakatime' to your `~/.vimrc` + +Then run these shell commands: + + sudo touch /var/log/wakatime.log + echo "api_key=MY_API_KEY" > ~/.wakatime + vim +BundleInstall +qall -automatic time tracking for Vim diff --git a/doc/wakatime.txt b/doc/wakatime.txt new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wakatime.py b/plugin/wakatime.py new file mode 100644 index 0000000..ac39235 --- /dev/null +++ b/plugin/wakatime.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python + +import os +import sys +import argparse +import platform +import urllib2 +import json +import base64 +import uuid +import time +import re +from collections import OrderedDict +from httplib import BadStatusLine, IncompleteRead +from urllib2 import HTTPError, URLError +import logging as log + + +# Config +version = '0.1.2' +user_agent = 'vim-wakatime/%s (%s)' % (version, platform.platform()) + + +def project_from_path(path): + project = git_project(path) + if project: + return project + return None + + +def tags_from_path(path): + tags = [] + if os.path.exists(path): + tags.extend(git_tags(path)) + tags.extend(mercurial_tags(path)) + return list(set(tags)) + + +def git_project(path): + config_file = find_git_config(path) + if config_file: + folder = os.path.split(os.path.split(os.path.split(config_file)[0])[0])[1] + if folder: + return folder + return None + + +def find_git_config(path): + path = os.path.realpath(path) + log.info(path) + if os.path.isfile(path): + path = os.path.split(path)[0] + if os.path.isfile(os.path.join(path, '.git', 'config')): + return os.path.join(path, '.git', 'config') + split_path = os.path.split(path) + if split_path[1] == '': + return None + return find_git_config(split_path[0]) + + +def parse_git_config(config): + sections = OrderedDict() + try: + f = open(config, 'r') + except IOError as e: + log.exception("Exception:") + else: + with f: + section = None + for line in f.readlines(): + line = line.lstrip() + if len(line) > 0 and line[0] == '[': + section = line[1:].split(']', 1)[0] + temp = section.split(' ', 1) + section = temp[0].lower() + if len(temp) > 1: + section = ' '.join([section, temp[1]]) + sections[section] = OrderedDict() + else: + try: + (setting, value) = line.split('=', 1) + except ValueError: + setting = line.split('#', 1)[0].split(';', 1)[0] + value = 'true' + setting = setting.strip().lower() + value = value.split('#', 1)[0].split(';', 1)[0].strip() + sections[section][setting] = value + f.close() + return sections + + +def git_tags(path): + tags = [] + config_file = find_git_config(path) + if config_file: + sections = parse_git_config(config_file) + for section in sections: + if section.split(' ', 1)[0] == 'remote' and 'url' in sections[section]: + tags.append(sections[section]['url']) + return tags + + +def mercurial_tags(path): + tags = [] + return tags + + +def svn_tags(path): + tags = [] + return tags + + +def log_action(**kwargs): + kwargs['User-Agent'] = user_agent + log.info(json.dumps(kwargs)) + + +def send_action(key, instance, action, task, timestamp, project, tags): + url = 'https://www.wakati.me/api/v1/actions' + data = { + 'type': action, + 'task': task, + 'time': time.time(), + 'instance_id': instance, + 'project': project, + 'tags': tags, + } + if timestamp: + data['time'] = timestamp + request = urllib2.Request(url=url, data=json.dumps(data)) + request.add_header('User-Agent', user_agent) + request.add_header('Content-Type', 'application/json') + request.add_header('Authorization', 'Basic %s' % base64.b64encode(key)) + log_action(**data) + response = None + try: + response = urllib2.urlopen(request) + except HTTPError as ex: + log.error("%s:\ndata=%s\nresponse=%s" % (ex.getcode(), json.dumps(data), ex.read())) + if log.getLogger().isEnabledFor(log.DEBUG): + log.exception("Exception for %s:\n%s" % (data['time'], json.dumps(data))) + except (URLError, IncompleteRead, BadStatusLine) as ex: + log.error("%s:\ndata=%s\nmessage=%s" % (ex.__class__.__name__, json.dumps(data), ex)) + if log.getLogger().isEnabledFor(log.DEBUG): + log.exception("Exception for %s:\n%s" % (data['time'], json.dumps(data))) + if response: + log.debug('response_code=%s response_content=%s' % (response.getcode(), response.read())) + if response and (response.getcode() == 200 or response.getcode() == 201): + return True + return False + + +def parse_args(argv): + parser = argparse.ArgumentParser(description='Log time to the wakati.me api') + parser.add_argument('--key', dest='key', required=True, + help='your wakati.me api key') + parser.add_argument('--action', dest='action', required=True, + choices=['open_file', 'ping', 'close_file', 'write_file', 'open_editor', 'quit_editor', 'minimize_editor', 'maximize_editor', 'start', 'stop']) + parser.add_argument('--task', dest='task', required=True, + help='path to file or named task') + parser.add_argument('--instance', dest='instance', required=True, + help='the UUID4 representing the current editor') + parser.add_argument('--time', dest='timestamp', metavar='time', type=float, + help='optional floating-point timestamp in seconds') + parser.add_argument('--verbose', dest='verbose', action='store_true', + help='turns on debug messages in logfile') + parser.add_argument('--version', action='version', version=version) + return parser.parse_args(argv) + + +def main(argv): + args = parse_args(argv) + level = log.INFO + if args.verbose: + level = log.DEBUG + del args.verbose + log.basicConfig(filename='/var/log/wakatime.log', format='%(asctime)s vim-wakatime/'+version+' %(levelname)s %(message)s', datefmt='%Y-%m-%dT%H:%M:%SZ', level=level) + tags = tags_from_path(args.task) + project = project_from_path(args.task) + send_action(project=project, tags=tags, **vars(args)) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/plugin/wakatime.vim b/plugin/wakatime.vim new file mode 100644 index 0000000..c248442 --- /dev/null +++ b/plugin/wakatime.vim @@ -0,0 +1,171 @@ +" ============================================================================ +" File: wakatime.vim +" Description: invisible time tracker using Wakati.Me +" Maintainer: Wakati.Me +" Version: 0.0.1 +" ============================================================================ + +" Init {{{ + +" Check Vim version +if v:version < 700 + echoerr "This plugin requires vim >= 7." + finish +endif + +" Check for Python support +if !has('python') + echoerr "This plugin requires Vim to be compiled with Python support." + finish +endif + +" Check for required user-defined settings +if !exists("g:wakatime_api_key") + if filereadable(expand("$HOME/.wakatime")) + for s:line in readfile(expand("$HOME/.wakatime")) + let s:setting = split(s:line, "=") + if s:setting[0] == "api_key" + let g:wakatime_api_key = s:setting[1] + endif + endfor + endif + if !exists("g:wakatime_api_key") + finish + endif +endif + +" Only load plugin once +if exists("g:loaded_wakatime") + finish +endif +let g:loaded_wakatime = 1 + +" Backup & Override cpoptions +let s:old_cpo = &cpo +set cpo&vim + +let s:plugin_directory = expand(":p:h") + +" Set a nice updatetime value, if updatetime is too short +if &updatetime < 60 * 1000 * 2 + let &updatetime = 60 * 1000 * 15 " 15 minutes +endif + +python << ENDPYTHON +import vim +import uuid +import time + +instance_id = str(uuid.uuid4()) +vim.command('let s:instance_id = "%s"' % instance_id) +ENDPYTHON + +" }}} + +" Function Definitions {{{ + +function! s:initVariable(var, value) + if !exists(a:var) + exec 'let ' . a:var . ' = ' . "'" . substitute(a:value, "'", "''", "g") . "'" + return 1 + endif + return 0 +endfunction + +function! s:GetCurrentFile() + return expand("%:p") +endfunction + +function! s:api(type, task) + exec "silent !python " . s:plugin_directory . "/wakatime.py --key" g:wakatime_api_key "--instance" s:instance_id "--action" a:type "--task" a:task . " &" +endfunction + +function! s:api_with_time(type, task, time) + exec "silent !python " . s:plugin_directory . "/wakatime.py --key" g:wakatime_api_key "--instance" s:instance_id "--action" a:type "--task" a:task "--time" printf("%f", a:time) . " &" +endfunction + +function! s:getchar() + let c = getchar() + if c =~ '^\d\+$' + let c = nr2char(c) + endif + return c +endfunction + +" }}} + +" Event Handlers {{{ + +function! s:bufenter() + let task = s:GetCurrentFile() + call s:api("open_file", shellescape(task)) +endfunction + +function! s:bufleave() + let task = s:GetCurrentFile() + call s:api("close_file", shellescape(task)) +endfunction + +function! s:vimenter() + let task = s:GetCurrentFile() + call s:api("open_editor", shellescape(task)) +endfunction + +function! s:vimleave() + let task = s:GetCurrentFile() + call s:api("quit_editor", shellescape(task)) +endfunction + +function! s:bufwrite() + let task = s:GetCurrentFile() + call s:api("write_file", shellescape(task)) +endfunction + +function! s:cursorhold() + let s:away_task = s:GetCurrentFile() + python vim.command("let s:away_start=%f" % (time.time() - (float(vim.eval("&updatetime")) / 1000.0))) + autocmd Wakatime CursorMoved,CursorMovedI * call s:cursormoved() +endfunction + +function! s:cursormoved() + autocmd! Wakatime CursorMoved,CursorMovedI * + python vim.command("let away_end=%f" % time.time()) + let away_unit = "minutes" + let away_duration = (away_end - s:away_start) / 60 + if away_duration > 59 + let away_duration = away_duration / 60 + let away_unit = "hours" + endif + if away_duration > 59 + let away_duration = away_duration / 60 + let away_unit = "days" + endif + let answer = input(printf("You were away %.f %s. Add time to current file? (y/n)", away_duration, away_unit)) + if answer != "y" + call s:api_with_time("minimize_editor", shellescape(s:away_task), s:away_start) + call s:api_with_time("maximize_editor", shellescape(s:away_task), away_end) + let s:away_start = 0 + else + call s:api("ping", shellescape(s:away_task)) + endif + "redraw! +endfunction + +" }}} + +" Autocommand Events {{{ + +augroup Wakatime + autocmd! + autocmd BufEnter * call s:bufenter() + autocmd BufLeave * call s:bufleave() + autocmd VimEnter * call s:vimenter() + autocmd VimLeave * call s:vimleave() + autocmd BufWritePost * call s:bufwrite() + autocmd CursorHold,CursorHoldI * call s:cursorhold() +augroup END + +" }}} + +" Restore cpoptions +let &cpo = s:old_cpo