Skip to content

Commit

Permalink
Customizable project name detection process (#14)
Browse files Browse the repository at this point in the history
* Add a framework for defining custom resolvers of project names

Add predefined resolvers for Projectile and Magit.

* Remove optional dependencies from the list of requirements

* Allow users to force a recompute of the project name

* Actually use the feature

* Fix the definition of a callback lambda

The invocation of the callback would crash because of a wrong number
of arguments, which would never let the system know that a bucket was
created, wasting CPU time on constantly resending requests for bucket creation.

* Document the changes in the README

* Require subr-x (built-in)
  • Loading branch information
VojtechStep authored Apr 16, 2020
1 parent ac41102 commit 9d591c5
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 6 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

## Installation

Heads Up! ActivityWatch depends on [request.el](https://tkf.github.io/emacs-request/) and [Projectile](https://github.com/bbatsov/projectile) being installed to work correctly.
Heads Up! ActivityWatch depends on [request.el](https://tkf.github.io/emacs-request/) being installed to work correctly.

It optionally depends on [Projectile](https://github.com/bbatsov/projectile) and [Magit](https://magit.vc) to detect project names.

1. Install activity-watch-mode for Emacs using [MELPA](https://melpa.org/#/activity-watch-mode).

Expand All @@ -25,6 +27,11 @@ Enable ActivityWatch for the current buffer by invoking `M-x activity-watch-mode

Set variable `activity-watch-api-host` to your activity watch local instance (default to `http://localhost:5600`).

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-<NAME>` and then adding `'NAME` to the list of resolvers `activity-watch-project-name-resolvers`. See its documentation for a list of predefined resolvers.

The default project name used when a proper one cannot be determined is "unknown" and can be customized via `activity-watch-project-name-default`.


## Acknowledgments

This mode is based of the [wakatime-mode](https://github.com/wakatime/wakatime-mode).
132 changes: 127 additions & 5 deletions activity-watch-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
;; Website: https://activitywatch.net
;; Homepage: https://github.com/pauldub/activity-watch-mode
;; Keywords: calendar, comm
;; Package-Requires: ((emacs "25") (projectile "0") (request "0") (json "0") (cl-lib "0"))
;; 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
Expand All @@ -34,14 +34,15 @@
;; Requires request.el (https://tkf.github.io/emacs-request/)
;;

;;; Dependencies: request, projectile, json, cl-lib
;;; 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")
Expand All @@ -56,6 +57,9 @@
(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
Expand All @@ -66,6 +70,118 @@
: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 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 '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)
Expand All @@ -91,18 +207,18 @@
(type . "app.editor.activity")))
:headers '(("Content-Type" . "application/json"))
:success (cl-function
(lambda (&allow-other-keys)
(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 (projectile-project-name))
(let ((project-name (activity-watch--get-project))
(file-name (buffer-file-name (current-buffer))))
`((timestamp . ,(ert--format-time-iso8601 time))
(duration . 0)
(data . ((language . ,(if (activity-watch--s-blank (symbol-name major-mode)) "unknown" major-mode))
(project . ,(if (activity-watch--s-blank project-name) "unknown" project-name))
(project . ,project-name)
(file . ,(if (activity-watch--s-blank file-name) "unknown" file-name)))))))


Expand Down Expand Up @@ -188,6 +304,12 @@ Argument DEFER Wether initialization should be deferred."
(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)."
Expand Down

0 comments on commit 9d591c5

Please sign in to comment.