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