├── HISTORY.rst ├── README.md └── activity-watch-mode.el /HISTORY.rst: -------------------------------------------------------------------------------- 1 | 2 | History 3 | ------- 4 | 5 | 1.0.0 (2018-10-16) 6 | ++++++++++++++++++ 7 | 8 | - Birth, forked from https://github.com/wakatime/wakatime-mode 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emacs Minor Mode for ActivityWatch 2 | 3 | [![MELPA](https://melpa.org/packages/activity-watch-mode-badge.svg)](https://melpa.org/#/activity-watch-mode) 4 | 5 | `activity-watch-mode` is an automatic time tracking extension for Emacs using [ActivityWatch](https://activitywatch.net/). 6 | 7 | ## Installation 8 | 9 | Heads Up! ActivityWatch depends on [request.el](https://tkf.github.io/emacs-request/) being installed to work correctly. 10 | 11 | It optionally depends on [Projectile](https://github.com/bbatsov/projectile) and [Magit](https://magit.vc) to detect project names. 12 | 13 | 1. Install activity-watch-mode for Emacs using [MELPA](https://melpa.org/#/activity-watch-mode). 14 | 15 | 3. Add `(global-activity-watch-mode)` to your `init.el` file, then restart Emacs. 16 | 17 | 6. Use Emacs with activity-watch-mode turned on and your time will be tracked for you automatically. 18 | 19 | 7. Visit http://localhost:5600 to see your logged time. 20 | 21 | ## Usage 22 | 23 | Enable ActivityWatch for the current buffer by invoking `M-x activity-watch-mode`. If you wish to activate it globally, run `M-x global-activity-watch-mode`. 24 | 25 | 26 | ## Configuration 27 | 28 | Set variable `activity-watch-api-host` to your activity watch local instance (default to `http://localhost:5600`). 29 | 30 | By default, the extension will try to infer the name of the project by consulting Projectile and Magit. Users can add resolution methods by defining functions in the form `activity-watch-project-name-` and then adding `'NAME` to the list of resolvers `activity-watch-project-name-resolvers`. See its documentation for a list of predefined resolvers. 31 | 32 | The default project name used when a proper one cannot be determined is "unknown" and can be customized via `activity-watch-project-name-default`. 33 | 34 | 35 | ## Acknowledgments 36 | 37 | This mode is based of the [wakatime-mode](https://github.com/wakatime/wakatime-mode). 38 | -------------------------------------------------------------------------------- /activity-watch-mode.el: -------------------------------------------------------------------------------- 1 | ;;; activity-watch-mode.el --- Automatic time tracking extension. -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2013 Gabor Torok 4 | 5 | ;; Author: Gabor Torok , Alan Hamlett 6 | ;; Maintainer: Paul d'Hubert 7 | ;; Website: https://activitywatch.net 8 | ;; Homepage: https://github.com/pauldub/activity-watch-mode 9 | ;; Keywords: calendar, comm 10 | ;; Package-Requires: ((emacs "25") (request "0") (json "0") (cl-lib "0")) 11 | ;; Version: 1.0.2 12 | 13 | ;; This program is free software; you can redistribute it and/or modify 14 | ;; it under the terms of the GNU General Public License as published by 15 | ;; the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU General Public License for more details. 22 | 23 | ;; You should have received a copy of the GNU General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; ActivityWatch mode based on https://github.com/wakatime/wakatime-mode 29 | ;; 30 | ;; Enable Activity-Watch for the current buffer by invoking 31 | ;; `activity-watch-mode'. If you wish to activate it globally, use 32 | ;; `global-activity-watch-mode'. 33 | ;; 34 | ;; Requires request.el (https://tkf.github.io/emacs-request/) 35 | ;; 36 | 37 | ;;; Dependencies: request, json, cl-lib 38 | 39 | ;;; Code: 40 | 41 | (require 'ert) 42 | (require 'request) 43 | (require 'json) 44 | (require 'cl-lib) 45 | (require 'subr-x) 46 | 47 | (defconst activity-watch-version "1.0.0") 48 | (defconst activity-watch-user-agent "emacs-activity-watch") 49 | (defvar activity-watch-noprompt nil) 50 | (defvar activity-watch-timer nil) 51 | (defvar activity-watch-idle-timer nil) 52 | (defvar activity-watch-init-started nil) 53 | (defvar activity-watch-init-finished nil) 54 | (defvar activity-watch-bucket-created nil) 55 | (defvar activity-watch-last-file-path nil) 56 | (defvar activity-watch-pulse-time 30) 57 | (defvar activity-watch-max-heartbeat-per-sec 1) 58 | (defvar activity-watch-last-heartbeat-time nil) 59 | 60 | (defvar-local activity-watch-project-name nil 61 | "Cached value of the project this file belongs to") 62 | 63 | (defgroup activity-watch nil 64 | "Customizations for Activity-Watch" 65 | :group 'convenience 66 | :prefix "activity-watch-") 67 | 68 | (defcustom activity-watch-api-host "http://localhost:5600" 69 | "API host for Activity-Watch." 70 | :type 'string 71 | :group 'activity-watch) 72 | 73 | (defcustom activity-watch-project-name-default "unknown" 74 | "Default name for a non-identifiable project." 75 | :type 'string 76 | :group 'activity-watch) 77 | 78 | (defcustom activity-watch-project-name-resolvers '(projectile project magit-dir-force magit-origin) 79 | "List of resolvers used to find the project name. 80 | 81 | When determining the name of a project, the watcher will go down the list 82 | and for each name tries to call the function \ 83 | `activity-watch-project-name-' with no parameters. 84 | If the function returns a non-emtpy string, it will be used as the project name. 85 | Otherwise, the following resolver in the list will be queried. 86 | 87 | If no resolver is able to identify the project, \ 88 | `activity-watch-project-name-default' is assumed. 89 | 90 | Methods provided by default are listed below. 91 | Every resolver that depends on an external package has a -force version. 92 | The default resolver checks if the package is loaded, and fails early if not. 93 | The forced resolver tries to `require' the package. 94 | 95 | projectile: 96 | projectile-force: 97 | Return the project name from `projectile-project-name'. 98 | 99 | magit-dir: 100 | magit-dir-force: 101 | Return the name of the directory where the repository is located. 102 | 103 | magit-origin: 104 | magit-origin-force: 105 | Return the name of the repository extracted from the 'origin' remote. 106 | 107 | cwd: 108 | Return the name of the current working directory." 109 | :type '(list symbol) 110 | :group 'activity-watch) 111 | 112 | (defmacro activity-watch--gen-feature-resolver (feature name &rest body) 113 | "Generate a pair of functions: `activity-watch-project-name-' \ 114 | and `activity-watch-project-name--force'. The forced version will try \ 115 | to `require' FEATURE first." 116 | (declare (indent 2)) 117 | (let ((func (intern (concat 118 | "activity-watch-project-name-" 119 | (symbol-name name)))) 120 | (forced (intern (concat 121 | "activity-watch-project-name-" 122 | (symbol-name name) 123 | "-force"))) 124 | (feature-name (cond 125 | ((symbolp feature) 126 | (symbol-name feature)) 127 | ((and (listp feature) (eq (car feature) 'quote)) 128 | (symbol-name (cadr feature))) 129 | (t ""))) 130 | (docstring (when (and (stringp (car body)) 131 | (cdr body)) 132 | (prog1 133 | (concat "\n\n" (car body)) 134 | (setq body (cdr body)))))) 135 | `(progn 136 | (defun ,func () 137 | ,(concat "Check if feature `" feature-name "' is provided, \ 138 | and when it is, use it to find the project's name." docstring) 139 | (when (featurep ,feature) 140 | ,@body)) 141 | (defun ,forced () 142 | ,(concat "Try to require feature `" feature-name "', and on success \ 143 | use it to find the project's name." docstring) 144 | (when (require ,feature nil t) 145 | ,@body))))) 146 | 147 | (activity-watch--gen-feature-resolver 'project project 148 | (when-let ((project (project-current))) 149 | (if (fboundp 'project-name) 150 | ;; `project-name' is a generic function added in Emacs 29.1 151 | (project-name project) 152 | ;; For earlier versions, use the generic function's default definition 153 | (file-name-nondirectory (directory-file-name (car (project-roots project))))))) 154 | 155 | (activity-watch--gen-feature-resolver 'projectile projectile 156 | (when (projectile-project-p) 157 | (projectile-project-name))) 158 | 159 | (activity-watch--gen-feature-resolver 'magit magit-dir 160 | "This implementation returns the directory name where the repository is saved localy." 161 | (when-let ((toplevel (magit-toplevel))) 162 | (file-name-nondirectory (directory-file-name toplevel)))) 163 | 164 | (activity-watch--gen-feature-resolver 'magit magit-origin 165 | "This implementation tries to parse the URL of the remote 'origin'." 166 | (when-let ((remote (magit-git-string "remote" "get-url" "origin")) 167 | (proj (string-trim (car (last (split-string-and-unquote remote "/"))) 168 | nil 169 | ".git"))) 170 | proj)) 171 | 172 | (defun activity-watch-project-name-cwd () 173 | "Return the name of the `default-directory'." 174 | (when default-directory 175 | (file-name-nondirectory (directory-file-name (expand-file-name default-directory))))) 176 | 177 | (defun activity-watch--get-project (&optional refresh) 178 | "Return the name of the project. If REFRESH is non-nil, disable cache. 179 | How the name is discoved depends on which resolvers are \ 180 | specified in `activity-watch-project-name-resolvers'." 181 | (setq-local activity-watch-project-name 182 | (or (and (not refresh) 183 | activity-watch-project-name) 184 | (cl-dolist (res activity-watch-project-name-resolvers) 185 | (if-let ((fun (intern (concat "activity-watch-project-name-" 186 | (symbol-name res)))) 187 | ((fboundp fun)) 188 | (proj (funcall fun)) 189 | ((not (activity-watch--s-blank proj)))) 190 | (cl-return proj))) 191 | activity-watch-project-name-default))) 192 | 193 | (defun activity-watch--s-blank (string) 194 | "Return non-nil if the STRING is empty or nil. Expects string." 195 | (or (null string) 196 | (zerop (length string)))) 197 | 198 | (defun activity-watch--init () 199 | "Initialize symbol ‘activity-watch-mode’." 200 | (unless activity-watch-init-started 201 | (setq activity-watch-init-started t) 202 | (setq activity-watch-init-finished t))) 203 | 204 | (defun activity-watch--bucket-id () 205 | "Return the bucket-id to be used when submitting heartbeats." 206 | (concat "aw-watcher-emacs_" (system-name))) 207 | 208 | (defun activity-watch--create-bucket () 209 | "Create the editor bucket." 210 | (when (not activity-watch-bucket-created) 211 | (request (concat activity-watch-api-host "/api/0/buckets/" (activity-watch--bucket-id)) 212 | :type "POST" 213 | :data (json-encode `((hostname . ,(system-name)) 214 | (client . ,activity-watch-user-agent) 215 | (type . "app.editor.activity"))) 216 | :headers '(("Content-Type" . "application/json")) 217 | :success (cl-function 218 | (lambda (&rest _ &allow-other-keys) 219 | (setq activity-watch-bucket-created t)))))) 220 | 221 | (defun activity-watch--create-heartbeat (time) 222 | "Create heartbeart to sent to the activity watch server. 223 | Argument TIME time at which the heartbeat was computed." 224 | (let ((project-name (activity-watch--get-project)) 225 | (file-name (buffer-file-name (current-buffer))) 226 | (git-branch (when (fboundp 'magit-get-current-branch) (magit-get-current-branch)))) 227 | `((timestamp . ,(ert--format-time-iso8601 time)) 228 | (duration . 0) 229 | (data . ((language . ,(if (activity-watch--s-blank (symbol-name major-mode)) "unknown" major-mode)) 230 | (project . ,project-name) 231 | (file . ,(if (activity-watch--s-blank file-name) "unknown" file-name)) 232 | (branch . ,(or git-branch "unknown"))))))) 233 | 234 | (cl-defun activity-watch--send-heartbeat (heartbeat &key (on-error nil) (on-success nil)) 235 | "Send HEARTBEAT to activity watch server, calling ON-ERROR on error and ON-SUCCESS on success." 236 | (request (concat activity-watch-api-host "/api/0/buckets/" (activity-watch--bucket-id) "/heartbeat") 237 | :type "POST" 238 | :params `(("pulsetime" . ,activity-watch-pulse-time)) 239 | :data (json-encode heartbeat) 240 | :headers '(("Content-Type" . "application/json")) 241 | :success on-success 242 | :error on-error 243 | )) 244 | 245 | (defun activity-watch--call () 246 | "Conditionally submit heartbeat to activity watch." 247 | (activity-watch--create-bucket) 248 | (let ((now (float-time)) 249 | (current-file-path (buffer-file-name (current-buffer))) 250 | (time-delta (+ (or activity-watch-last-heartbeat-time 0) activity-watch-max-heartbeat-per-sec)) 251 | (coding-system-for-read 252 | (unless (eq coding-system-for-read 'auto-save-coding) 253 | coding-system-for-read))) 254 | (if (or (not (string= (or activity-watch-last-file-path "") current-file-path)) 255 | (< time-delta now)) 256 | (progn 257 | (setq activity-watch-last-file-path current-file-path) 258 | (setq activity-watch-last-heartbeat-time now) 259 | (activity-watch--send-heartbeat (activity-watch--create-heartbeat (current-time)) 260 | :on-error (cl-function (lambda (&key data &allow-other-keys) 261 | (message data) (global-activity-watch-mode 0) (activity-watch-mode 0))) 262 | ))))) 263 | 264 | (defun activity-watch--save () 265 | "Send save notice to Activity-Watch." 266 | (save-match-data 267 | (when (and (buffer-file-name (current-buffer)) 268 | (not (auto-save-file-name-p (buffer-file-name (current-buffer))))) 269 | (activity-watch--call)))) 270 | 271 | (defun activity-watch--start-timer () 272 | "Start timers for heartbeat submission and idling." 273 | (unless activity-watch-timer 274 | (setq activity-watch-timer (run-at-time t 2 #'activity-watch--save))) 275 | (unless activity-watch-idle-timer 276 | ;; stop the timer after 30s inactivity 277 | (setq activity-watch-idle-timer (run-with-idle-timer 30 t #'activity-watch--stop-timer)))) 278 | 279 | (defun activity-watch--stop-timer () 280 | "Stop heartbeat submission timer." 281 | (when activity-watch-timer 282 | (cancel-timer activity-watch-timer) 283 | (setq activity-watch-timer nil))) 284 | 285 | (defun activity-watch--stop-idle-timer () 286 | "Stop idling timer." 287 | (when activity-watch-idle-timer 288 | (cancel-timer activity-watch-idle-timer) 289 | (setq activity-watch-idle-timer nil))) 290 | 291 | (defun activity-watch--bind-hooks () 292 | "Watch for activity in buffers." 293 | (add-hook 'pre-command-hook #'activity-watch--start-timer nil t) 294 | (add-hook 'after-save-hook #'activity-watch--save nil t) 295 | (add-hook 'auto-save-hook #'activity-watch--save nil t) 296 | (add-hook 'first-change-hook #'activity-watch--save nil t)) 297 | 298 | (defun activity-watch--unbind-hooks () 299 | "Stop watching for activity in buffers." 300 | (remove-hook 'pre-command-hook #'activity-watch--start-timer t) 301 | (remove-hook 'after-save-hook #'activity-watch--save t) 302 | (remove-hook 'auto-save-hook #'activity-watch--save t) 303 | (remove-hook 'first-change-hook #'activity-watch--save t)) 304 | 305 | (defun activity-watch-turn-on (defer) 306 | "Turn on Activity-Watch. 307 | Argument DEFER Wether initialization should be deferred." 308 | (if defer 309 | (run-at-time "1 sec" nil #'activity-watch-turn-on nil) 310 | (progn 311 | (activity-watch--init) 312 | (if activity-watch-init-finished 313 | (progn (activity-watch--bind-hooks) (activity-watch--start-timer)) 314 | (run-at-time "1 sec" nil #'activity-watch-turn-on nil))))) 315 | 316 | (defun activity-watch-turn-off () 317 | "Turn off Activity-Watch." 318 | (activity-watch--unbind-hooks) 319 | (activity-watch--stop-timer) 320 | (activity-watch--stop-idle-timer)) 321 | 322 | ;;;###autoload 323 | (defun activity-watch-refresh-project-name () 324 | "Recompute the name of the project for the current file." 325 | (interactive) 326 | (activity-watch--get-project t)) 327 | 328 | ;;;###autoload 329 | (define-minor-mode activity-watch-mode 330 | "Toggle Activity-Watch (Activity-Watch mode)." 331 | :lighter " activity-watch" 332 | :init-value nil 333 | :global nil 334 | :group 'activity-watch 335 | (cond 336 | (noninteractive (setq activity-watch-mode nil)) 337 | (activity-watch-mode (activity-watch-turn-on t)) 338 | (t (activity-watch-turn-off)))) 339 | 340 | ;;;###autoload 341 | (define-globalized-minor-mode global-activity-watch-mode 342 | activity-watch-mode 343 | (lambda () (activity-watch-mode 1)) 344 | :require 'activity-watch-mode) 345 | 346 | (provide 'activity-watch-mode) 347 | ;;; activity-watch-mode.el ends here 348 | --------------------------------------------------------------------------------